diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index cffd737..5ce37d6 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -33,6 +33,8 @@ jobs:
sudo apt-get update
sudo apt-get install --yes openssh-client openssh-server
fi
+ - name: Check source manifest
+ run: python scripts/source_manifest.py --check
- name: Run offline tests
run: python -m unittest discover -s tests -v
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d240870..111e596 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## Unreleased — v0.4.0 development
+
+- Add offline `dots-ts version` reporting for package identity, source-file integrity and the separate Tailscale binary version (#27).
+- Require a matching source manifest for new installations; preserve unknown metadata for legacy installations instead of guessing a version.
+- Add package integrity to `doctor` and document the development feature in all four languages.
+
+This development work has not been deployed to Dots. Release checks, notifications and verified updates remain separate work in #28–#30.
+
## v0.3.1 — 2026-10-19
- Wait for transient startup backend states within a bounded readiness deadline and distinguish authentication, approval and query failures (#25).
diff --git a/README.ja.md b/README.ja.md
index 9dcac7b..bb706d1 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -47,6 +47,10 @@ bash /workspace/.dots-tailscale/start.sh login
`doctor` は状態を変更せず、ローカルの診断情報を読み取ります。`cli` はインストール済みの Tailscale バイナリとソケットを選択します。任意で使える loopback SOCKS5/HTTP プロキシは既定で無効です。有効にするには、デーモンを再起動する手順を別途確認してください。設定、アクセス方針、検証範囲については、[使用ガイド](docs/usage.ja.md#development-helpers)と[プライベート HTTP API の利用例](docs/private-http-api.ja.md)を参照してください。これらのプロキシは ChatGPT コネクター、MCP クライアント、Dots ブラウザーには自動設定されません。
+## 開発中:インストール済みパッケージのバージョン確認
+
+開発版では `dots-ts version` と `dots-ts version --json` で、dots-tailscale パッケージと Tailscale バイナリのバージョンを別々に確認できます。v0.3.1 にはまだ含まれていません。詳しくは[パッケージのバージョンとファイルの確認](docs/usage.ja.md#package-version)を参照してください。
+
## 同梱内容
| パス | 用途 |
diff --git a/README.ko.md b/README.ko.md
index 3a32fd0..ec6c6f4 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -47,6 +47,10 @@ bash /workspace/.dots-tailscale/start.sh login
`doctor`는 상태를 바꾸지 않고 로컬 진단 정보를 읽습니다. `cli`는 설치된 Tailscale 실행 파일과 소켓을 선택합니다. 선택 기능인 로컬 SOCKS5·HTTP 프록시는 기본적으로 꺼져 있으며, 활성화하려면 데몬 재시작을 별도로 검토해야 합니다. 설정과 접근 정책, 검증 범위는 [사용 가이드](docs/usage.ko.md#development-helpers)와 [비공개 HTTP API 연결 예시](docs/private-http-api.ko.md)를 참고하세요. ChatGPT 커넥터·MCP 클라이언트·Dots 브라우저에 자동으로 적용되지는 않습니다.
+## 개발 중: 설치된 패키지 버전 확인
+
+개발 버전의 `dots-ts version`과 `dots-ts version --json`은 dots-tailscale 패키지 버전과 내부 Tailscale 버전을 따로 보여줍니다. v0.3.1에는 아직 없는 명령입니다. 자세한 내용은 [패키지 버전과 파일 확인](docs/usage.ko.md#package-version)을 참고하세요.
+
## 구성 요소
| 경로 | 역할 |
diff --git a/README.md b/README.md
index 96fba7d..af450d6 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,10 @@ Install all four source files from [v0.3.1](https://github.com/abruption/dots-ta
`doctor` reads local diagnostics without changing state. `cli` selects the installed Tailscale binary and socket. Optional loopback SOCKS5/HTTP proxies are disabled by default and require a separately reviewed daemon restart to enable. See the [usage guide](docs/usage.md#development-helpers) and [private HTTP API recipe](docs/private-http-api.md) for configuration, access policy, and validation limits. These proxies do not automatically configure ChatGPT connectors, MCP clients, or the Dots browser.
+## Development: installed package version
+
+The development build adds `dots-ts version` and `dots-ts version --json` to show the dots-tailscale package version separately from the Tailscale binary version. These commands are not included in v0.3.1. See [package version and file verification](docs/usage.md#package-version).
+
## Included
| Path | Purpose |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 1719da9..9d1926e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -47,6 +47,10 @@ bash /workspace/.dots-tailscale/start.sh login
`doctor` 读取本地诊断信息,不会更改状态。`cli` 会选择已安装的 Tailscale 二进制文件和套接字。可选的回环 SOCKS5/HTTP 代理默认关闭;启用前需要单独审查并重启守护进程。配置、访问策略和验证范围请参阅[使用指南](docs/usage.zh-CN.md#development-helpers)和[私有 HTTP API 配置示例](docs/private-http-api.zh-CN.md)。这些代理不会自动配置 ChatGPT 连接器、MCP 客户端或 Dots 浏览器。
+## 开发中:查看已安装的软件包版本
+
+开发版新增了 `dots-ts version` 和 `dots-ts version --json`,可以分别查看 dots-tailscale 软件包版本和 Tailscale 程序版本。v0.3.1 尚不包含这些命令。详情见[软件包版本与文件校验](docs/usage.zh-CN.md#package-version)。
+
## 项目内容
| 路径 | 用途 |
diff --git a/VERSION b/VERSION
index 9e11b32..25a5afd 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.1
+0.4.0-dev
diff --git a/docs/usage.ja.md b/docs/usage.ja.md
index eb8e766..a7e5e7f 100644
--- a/docs/usage.ja.md
+++ b/docs/usage.ja.md
@@ -138,6 +138,27 @@ backend の準備確認では、各フェーズにつき単調時計で計る30
このリポジトリには VM cron、systemd サービス、自動起動登録はありません。Dots オートメーション経由の起動も未検証です。VM 内のプロセスループは、その環境が破棄されると存続できません。新しい環境でインストールを明示的に起動するか、別途検証済みのプラットフォーム実行手段を使ってください。
+
+
+## パッケージのバージョンとファイルの確認(開発中)
+
+v0.4.0 に向けて開発中の機能です。上記の v0.3.1 のインストール例には含まれません。新しいリリースを公開しても、既存のインストールは自動で更新されません。
+
+```bash
+/workspace/.dots-tailscale/dots-ts version
+/workspace/.dots-tailscale/dots-ts version --json
+```
+
+GitHub や tailnet には接続せず、ローカルのパッケージファイルを確認します。マニフェストに記載されたパッケージのバージョン、ソースファイルの一致状況、インストール済み Tailscale バイナリのバージョンを別々に表示します。ソースのリビジョンが不明なら、そのまま不明として扱います。マニフェストとの一致はファイルの整合性を示しますが、配布元の本人確認や、動作中のサービスが正常であることの証明にはなりません。
+
+確認済みの同じリビジョンから取得したソースファイル 4 つと `source-manifest.json` を一緒に用意してください。新しいインストーラーはこの組み合わせを検証し、マニフェストをインストール先に保存します。不一致を消すために、変更済みのインストールファイルから期待するハッシュを作り直さないでください。メタデータのない既存の v0.2/v0.3 環境では、パッケージのバージョンは不明になります。Tailscale のバージョンだけでは判別できません。
+
+更新やロールバックの際は、ソースファイルと対応する元のマニフェストをセットで保管し、一緒に入れ替えてください。古いコードに新しいマニフェストだけを上書きしないでください。ノードの状態、秘密鍵、SSH の識別情報、プロキシ設定は保持します。以下の手動更新の注意事項も引き続き適用されます。このコマンド自体は更新や再起動を行いません。
+
+JSON では `package.declared_version`、`package.source_revision`、`package.integrity` とファイルごとの結果を、`tailscale.version`、`tailscale.status` と分けて表示します。整合性は `match`、`mismatch`(変更または異なるバージョンの混在)、`incomplete`(ファイルの欠落または読み取り不可)、`unknown`(メタデータの欠落、不正または読み取り不可)のいずれかです。`source_revision` は現在 `null` で、コミットを推測しません。mismatch/incomplete の終了コードは `1`、それ以外は `0` です。終了コード `0` だけではバージョンが判明したことや Tailscale バイナリが利用可能であることを示しません。`doctor` はメタデータが不明なら degraded、ファイルの不一致や不足は unhealthy と表示します。
+
+メンテナンス時は、確認済みのソース変更後に `python3 scripts/source_manifest.py` を実行し、差分を確認してから `python3 scripts/source_manifest.py --check` で検証します。CI はファイルを書き換えず、検証のみ行います。生成は確認済みのソースチェックアウトで行い、インストール先では行わないでください。リリースにはソースファイル 4 つに対応するマニフェストも含める必要があります。既存の v0.3.1 リリースには含まれていません。
+
## v0.3.1 ヘルパーと任意のアプリケーションプロキシ
diff --git a/docs/usage.ko.md b/docs/usage.ko.md
index 4948be8..2f86645 100644
--- a/docs/usage.ko.md
+++ b/docs/usage.ko.md
@@ -87,6 +87,27 @@ ssh -o StrictHostKeyChecking=ask -i /path/to/client_private_key 'agent@' 'id; pwd'`로 접속할 수 있습니다. 사용자명과 SSH 키 경로는 자신의 설정에 맞게 지정하세요. 이후 지문이 달라지면 Dots GUI 터미널에서 다시 확인하세요.
+
+
+## 패키지 버전과 파일 확인 (개발 중)
+
+v0.4.0을 위해 개발 중인 기능입니다. 위의 v0.3.1 설치 예시에는 포함되지 않습니다. 새 릴리스를 발행해도 기존 설치가 자동으로 바뀌지는 않습니다.
+
+```bash
+/workspace/.dots-tailscale/dots-ts version
+/workspace/.dots-tailscale/dots-ts version --json
+```
+
+GitHub나 tailnet에 접속하지 않고 로컬 패키지 파일을 확인합니다. 명세 파일에 적힌 패키지 버전, 소스 파일 일치 여부, 설치된 Tailscale 실행 파일의 버전을 각각 보여줍니다. 소스 리비전을 알 수 없으면 그대로 알 수 없다고 표시합니다. 파일이 명세와 일치한다고 해서 배포자의 신원이나 실행 중인 서비스의 정상 동작까지 증명되는 것은 아닙니다.
+
+같은 검토된 리비전의 소스 파일 네 개와 `source-manifest.json`을 함께 준비하세요. 새 설치기는 이 묶음을 검증한 뒤 명세를 설치 경로에 보존합니다. 불일치를 없애려고 수정된 설치 파일로 기대 체크섬을 다시 만들면 안 됩니다. 메타데이터가 없는 기존 v0.2/v0.3 설치는 패키지 버전을 알 수 없다고 표시합니다. 내부 Tailscale 버전만으로 패키지 버전을 판단할 수는 없습니다.
+
+업데이트나 원복을 준비할 때는 소스 파일과 해당 원본 명세를 한 묶음으로 보관하고 함께 교체하세요. 오래된 코드에 새 명세만 덮어쓰지 마세요. 노드 상태, 개인키, SSH 식별 정보와 프록시 설정은 보존합니다. 아래 수동 업데이트 주의사항도 그대로 적용됩니다. 이 명령 자체는 업데이트하거나 재시작하지 않습니다.
+
+JSON은 `package.declared_version`, `package.source_revision`, `package.integrity`, 파일별 결과를 `tailscale.version` 및 `tailscale.status`와 구분합니다. 일치 여부는 `match`, 수정되거나 다른 버전이 섞인 `mismatch`, 파일이 없거나 읽을 수 없는 `incomplete`, 메타데이터가 없거나 잘못됐거나 읽을 수 없는 `unknown`으로 표시합니다. `source_revision`은 현재 `null`이며 커밋을 추측하지 않습니다. mismatch/incomplete는 종료 코드 `1`, 나머지는 `0`입니다. 따라서 종료 코드 `0`만으로 버전을 확인했거나 Tailscale 실행 파일이 정상이라고 판단하면 안 됩니다. `doctor`는 메타데이터를 알 수 없으면 degraded, 파일 불일치·누락은 unhealthy로 표시합니다.
+
+유지보수 시에는 검토한 소스 변경 후 `python3 scripts/source_manifest.py`로 명세를 생성하고 차이를 확인한 뒤 `python3 scripts/source_manifest.py --check`로 검사합니다. CI는 파일을 수정하지 않고 검사만 합니다. 설치 경로가 아닌 검토된 소스 체크아웃에서만 생성하세요. 릴리스에는 소스 파일 네 개와 일치하는 명세도 함께 배포해야 합니다. 기존 v0.3.1 릴리스에는 이 명세가 없습니다.
+
## v0.3.1 도구와 선택적 애플리케이션 프록시
diff --git a/docs/usage.md b/docs/usage.md
index 7feecb5..fb0511f 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -138,6 +138,27 @@ The custom TLS/WebSocket shim implements the configured local HTTP CONNECT bridg
There is no VM cron, systemd service, or automatic startup registration in this repository. Startup through Dots automation has not been tested. A process loop inside the VM cannot survive destruction of that environment. Start the installation explicitly in a new environment or through a separately verified platform execution mechanism.
+
+
+## Package version and file verification (development)
+
+This feature is in development for v0.4.0; the v0.3.1 installation examples above do not include it. Publishing a release does not upgrade an existing installation.
+
+```bash
+/workspace/.dots-tailscale/dots-ts version
+/workspace/.dots-tailscale/dots-ts version --json
+```
+
+The command checks local package files without contacting GitHub or the tailnet. It reports the manifest’s declared package version, source-file integrity, and the installed Tailscale binary version separately. An unknown source revision stays unknown. A matching manifest proves file consistency, not the identity of the publisher or readiness of the running service.
+
+Keep `source-manifest.json` with the four source files from the same reviewed revision. The new installer verifies that bundle and preserves the manifest in the installation. Never generate a new expected manifest from modified installed files to make a mismatch disappear. Existing v0.2/v0.3 installations without metadata report an unknown package version; the Tailscale version alone cannot identify the package.
+
+When staging an update or a rollback, keep the original manifest with its matching source files and restore them together. Do not copy a newer manifest over older code. Preserve node state, private keys, SSH identity and proxy settings. The manual update precautions below still apply; this command does not update or restart anything.
+
+JSON separates `package.declared_version`, `package.source_revision`, `package.integrity` and per-file results from `tailscale.version` and `tailscale.status`. Integrity is `match`, `mismatch` (modified or mixed files), `incomplete` (missing or unreadable files), or `unknown` (missing, invalid or unreadable metadata). `source_revision` is currently `null`; no commit is guessed. Exit code is `1` for mismatch/incomplete and `0` otherwise, so exit `0` alone does not prove a known version or available Tailscale binary. `doctor` reports unknown metadata as degraded and mismatch/incomplete as unhealthy.
+
+For maintainers: after intentional source changes, run `python3 scripts/source_manifest.py`, review the resulting diff, then run `python3 scripts/source_manifest.py --check`. CI runs the check without rewriting files. Regenerate only in the reviewed source checkout, not in an installed directory. A release must distribute the matching manifest alongside the four source assets; the existing v0.3.1 release does not contain it.
+
## v0.3.1 helpers and optional application proxies
diff --git a/docs/usage.zh-CN.md b/docs/usage.zh-CN.md
index 9dec75b..9ee877a 100644
--- a/docs/usage.zh-CN.md
+++ b/docs/usage.zh-CN.md
@@ -138,6 +138,27 @@ ssh -o StrictHostKeyChecking=yes -i /path/to/client_private_key 'agent@
+
+## 软件包版本与文件校验(开发中)
+
+这是为 v0.4.0 开发的功能,上面的 v0.3.1 安装示例尚不包含它。发布新版本不会自动更新已有安装。
+
+```bash
+/workspace/.dots-tailscale/dots-ts version
+/workspace/.dots-tailscale/dots-ts version --json
+```
+
+命令只检查本地软件包文件,不连接 GitHub 或 tailnet。它分别显示清单声明的软件包版本、源文件的一致性和已安装的 Tailscale 程序版本。无法确定的源码修订版本会保留为未知。文件与清单一致,只能说明文件一致性,不能证明发布者身份,也不能证明正在运行的服务正常。
+
+请将同一已审核修订版本的四个源文件与 `source-manifest.json` 放在一起。新安装程序会校验这些文件,并将清单保存在安装目录中。不要用修改后的安装文件重新生成预期校验值来掩盖不一致。没有元数据的旧版 v0.2/v0.3 安装会显示软件包版本未知,不能仅凭 Tailscale 版本推断软件包版本。
+
+准备更新或回滚时,请把源文件与对应的原始清单作为一组保存并一起替换。不要只把新清单覆盖到旧代码上。保留节点状态、私钥、SSH 身份和代理设置。下方手动更新的注意事项仍然适用;此命令本身不会更新或重启服务。
+
+JSON 将 `package.declared_version`、`package.source_revision`、`package.integrity` 和逐文件结果,与 `tailscale.version`、`tailscale.status` 分开显示。一致性结果为 `match`、`mismatch`(文件被修改或版本混用)、`incomplete`(文件缺失或不可读)或 `unknown`(元数据缺失、无效或不可读)。目前 `source_revision` 为 `null`,不会猜测提交版本。mismatch/incomplete 的退出码为 `1`,其余为 `0`;因此,退出码 `0` 本身并不代表版本已知或 Tailscale 程序可用。`doctor` 将未知元数据标为 degraded,将文件不一致或不完整标为 unhealthy。
+
+维护时,在确认源码修改后运行 `python3 scripts/source_manifest.py`,检查生成的差异,再运行 `python3 scripts/source_manifest.py --check`。CI 只检查,不重写文件。请只在已审核的源码检出目录中生成清单,不要在安装目录中生成。发布时需要将匹配的清单与四个源文件一起提供;现有 v0.3.1 发布包不包含此清单。
+
## v0.3.1 辅助工具与可选应用代理
diff --git a/dots-ts b/dots-ts
index 3cfab46..d6227f8 100755
--- a/dots-ts
+++ b/dots-ts
@@ -1,6 +1,9 @@
#!/usr/bin/env python3
"""Local Tailscale CLI forwarding and bounded, shareable diagnostics."""
import argparse
+import hashlib
+import selectors
+import time
import json
import os
import pathlib
@@ -14,6 +17,120 @@ sys.dont_write_bytecode = True
MANAGEMENT_TIMEOUT = 5
+MANIFEST = "source-manifest.json"
+CORE_FILES = {"tailscale-setup.sh": "setup.sh", "shim.py": "shim/shim.py",
+ "dots-ts": "dots-ts", "dots_proxy.py": "dots_proxy.py"}
+PACKAGE_VERSION_PATTERN = r"[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9][a-zA-Z0-9.-]*)?"
+
+
+def bounded_code_read(directory, relative, limit):
+ """Open only regular files beneath directory, without following symlinks."""
+ descriptor = os.open(str(directory), os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ try:
+ parts = pathlib.PurePosixPath(relative).parts
+ for part in parts[:-1]:
+ next_descriptor = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
+ dir_fd=descriptor)
+ os.close(descriptor)
+ descriptor = next_descriptor
+ fd = os.open(parts[-1], os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=descriptor)
+ with os.fdopen(fd, "rb") as stream:
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
+ raise ValueError("not_regular")
+ data = stream.read(limit + 1)
+ if len(data) > limit:
+ raise ValueError("too_large")
+ return data
+ finally:
+ os.close(descriptor)
+
+
+def package_report(directory, layout="installed", manifest_directory=None):
+ report = {"declared_version": None, "source_revision": None,
+ "integrity": "unknown", "reason": "metadata_missing", "files": {}}
+ try:
+ raw = bounded_code_read(manifest_directory or directory, MANIFEST, 16384)
+ except FileNotFoundError:
+ return report
+ except (OSError, ValueError):
+ report["reason"] = "metadata_unavailable"
+ return report
+ try:
+ def unique_object(pairs):
+ result = {}
+ for key, value in pairs:
+ if key in result:
+ raise ValueError("duplicate_key")
+ result[key] = value
+ return result
+ metadata = json.loads(raw, object_pairs_hook=unique_object)
+ if (not isinstance(metadata, dict) or set(metadata) != {"schema_version", "package_version", "files"}
+ or type(metadata["schema_version"]) is not int or metadata["schema_version"] != 1
+ or not isinstance(metadata["package_version"], str)
+ or len(metadata["package_version"]) > 80
+ or not re.fullmatch(PACKAGE_VERSION_PATTERN, metadata["package_version"])
+ or not isinstance(metadata["files"], dict) or set(metadata["files"]) != set(CORE_FILES)
+ or any(not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value)
+ for value in metadata["files"].values())):
+ raise ValueError("invalid_metadata")
+ except (ValueError, TypeError, UnicodeError, RecursionError):
+ report["reason"] = "metadata_invalid"
+ return report
+ report["declared_version"] = metadata["package_version"]
+ for source, installed in CORE_FILES.items():
+ try:
+ payload = bounded_code_read(directory, source if layout == "source" else installed, 8 * 1024 * 1024)
+ status = "match" if hashlib.sha256(payload).hexdigest() == metadata["files"][source] else "mismatch"
+ except FileNotFoundError:
+ status = "missing"
+ except (OSError, ValueError):
+ status = "unavailable"
+ report["files"][source] = status
+ values = report["files"].values()
+ report["integrity"] = ("mismatch" if "mismatch" in values else "incomplete" if "missing" in values
+ else "incomplete" if "unavailable" in values else "match")
+ report["reason"] = {"match": "matches_manifest", "mismatch": "modified_or_mixed",
+ "incomplete": "files_unavailable" if "unavailable" in values else "files_missing", "unknown": "files_unavailable"}[report["integrity"]]
+ return report
+
+
+def tailscale_version(directory):
+ """Bound both execution time and captured output; never contact the daemon."""
+ process = None
+ try:
+ process = subprocess.Popen([str(directory / "tailscale"), "version"],
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+ deadline = time.monotonic() + MANAGEMENT_TIMEOUT
+ output = bytearray()
+ with selectors.DefaultSelector() as selector:
+ selector.register(process.stdout, selectors.EVENT_READ)
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0 or not selector.select(remaining):
+ return {"version": None, "status": "timeout"}
+ chunk = os.read(process.stdout.fileno(), 4097 - len(output))
+ if not chunk:
+ break
+ output.extend(chunk)
+ if len(output) > 4096:
+ return {"version": None, "status": "invalid_response"}
+ code = process.wait(timeout=max(0.001, deadline - time.monotonic()))
+ first = bytes(output).decode("ascii").splitlines()[0] if output else ""
+ if code or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?", first):
+ return {"version": None, "status": "invalid_response"}
+ return {"version": first, "status": "available"}
+ except subprocess.TimeoutExpired:
+ return {"version": None, "status": "timeout"}
+ except (OSError, UnicodeError):
+ return {"version": None, "status": "unavailable"}
+ finally:
+ if process is not None:
+ if process.poll() is None:
+ process.kill()
+ process.wait()
+ process.stdout.close()
+
+
def directories():
directory = pathlib.Path(os.environ.get("DOTS_TS_DIR", str(pathlib.Path(__file__).resolve().parent)))
if not directory.is_absolute():
@@ -275,7 +392,7 @@ def doctor(directory, runtime, target=None):
files = ("installed", "setup.sh", "start.sh", "dots-ts", "dots_proxy.py", "tailscale", "tailscaled", "shim/shim.py",
"shim/ca.crt", "shim/ca.key", "shim/bundle.crt", "sshd/sshd_config",
"sshd/ssh_host_ed25519_key", "sshd/authorized_keys")
- checks = {"files": {name: installed_file_status(directory / name) for name in files},
+ checks = {"package": package_report(directory), "files": {name: installed_file_status(directory / name) for name in files},
"processes": process_checks(directory),
"state": installed_file_status(directory / "state/tailscaled.state"),
"socket": socket_status(runtime / "tailscaled.sock"),
@@ -338,7 +455,7 @@ def doctor(directory, runtime, target=None):
# A broken/unsupported inspection is unknown, never echoed verbatim.
checks["proxy"] = {"desired": "unknown", "running": "unknown", "restart_required": None,
"inspection": "unavailable"}
- unhealthy = (any(value != "present" for value in checks["files"].values()) or checks["socket"] in ("missing", "access_denied", "unsafe_symlink", "not_socket")
+ unhealthy = (checks["package"]["integrity"] in ("mismatch", "incomplete") or any(value != "present" for value in checks["files"].values()) or checks["socket"] in ("missing", "access_denied", "unsafe_symlink", "not_socket")
or "not_running" in checks["processes"].values()
or checks["backend"] in ("needs_login", "no_state", "stopped", "needs_approval", "unavailable", "timeout", "invalid_response")
or checks["state"] in ("missing", "empty", "unsafe_symlink", "not_regular")
@@ -347,7 +464,7 @@ def doctor(directory, runtime, target=None):
or item["writable"] == "denied" or item["visibility"] != "present"
for item in (context["installation"], context["runtime"]))
context_unknown = any("unknown" in item.values() for item in (context["installation"], context["runtime"]))
- degraded = (context_restricted or context_unknown or checks["socket"] == "unknown"
+ degraded = (checks["package"]["integrity"] == "unknown" or context_restricted or context_unknown or checks["socket"] == "unknown"
or "unknown" in checks["processes"].values() or checks["backend"] in ("unknown", "starting")
or checks["state"] == "unknown" or checks["proxy"].get("desired") == "unknown"
or checks["proxy"].get("restart_required", False))
@@ -371,6 +488,14 @@ class SafeParser(argparse.ArgumentParser):
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
try:
+ if argv and argv[0] == "_verify-package":
+ if len(argv) != 4 or argv[2] not in ("source", "installed"):
+ raise ValueError("invalid_arguments")
+ report = package_report(pathlib.Path(argv[1]), argv[2], pathlib.Path(argv[3]))
+ if report["integrity"] != "match":
+ print("dots-ts: source bundle " + report["reason"], file=sys.stderr)
+ return 1
+ return 0
directory, runtime = directories()
if argv and argv[0] == "cli":
os.execv(str(directory / "tailscale"), [str(directory / "tailscale"), "--socket=" + str(runtime / "tailscaled.sock")] + argv[1:])
@@ -385,10 +510,23 @@ def main(argv=None):
return 1
parser = SafeParser(description="Local CLI forwarding and read-only diagnostics.")
commands = parser.add_subparsers(dest="command", required=True)
+ version = commands.add_parser("version", help="offline package integrity and local Tailscale version")
+ version.add_argument("--json", action="store_true")
diagnostic = commands.add_parser("doctor", help="bounded read-only checks; target probes are TCP only")
diagnostic.add_argument("--json", action="store_true")
diagnostic.add_argument("--target")
options = parser.parse_args(argv)
+ if options.command == "version":
+ report = {"package": package_report(directory), "tailscale": tailscale_version(directory)}
+ if options.json:
+ print(json.dumps(report, sort_keys=True))
+ else:
+ package = report["package"]
+ print("dots-ts package: " + (package["declared_version"] or "unknown"))
+ print("source revision: unknown")
+ print("source integrity: " + package["integrity"] + " (" + package["reason"] + ")")
+ print("Tailscale: " + (report["tailscale"]["version"] or report["tailscale"]["status"]))
+ return int(report["package"]["integrity"] in ("mismatch", "incomplete"))
if options.target is not None:
target_parts(options.target)
report, code = doctor(directory, runtime, options.target)
diff --git a/scripts/source_manifest.py b/scripts/source_manifest.py
new file mode 100644
index 0000000..aefee29
--- /dev/null
+++ b/scripts/source_manifest.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+"""Explicitly regenerate package metadata, or check it without writes in CI."""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+FILES = ("tailscale-setup.sh", "shim.py", "dots-ts", "dots_proxy.py")
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--check", action="store_true")
+ args = parser.parse_args()
+ expected = {"schema_version": 1, "package_version": (ROOT / "VERSION").read_text().strip(),
+ "files": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in FILES}}
+ destination = ROOT / "source-manifest.json"
+ serialized = json.dumps(expected, indent=2, sort_keys=True) + "\n"
+ if args.check:
+ if not destination.is_file() or destination.read_text() != serialized:
+ parser.exit(1, "source manifest is stale; run python3 scripts/source_manifest.py\n")
+ else:
+ destination.write_text(serialized)
+
+if __name__ == "__main__":
+ main()
diff --git a/source-manifest.json b/source-manifest.json
new file mode 100644
index 0000000..39bb1cd
--- /dev/null
+++ b/source-manifest.json
@@ -0,0 +1,10 @@
+{
+ "files": {
+ "dots-ts": "69dde01ff73107965f2bdf0b020088386225d59e971f9fa94751b5f9bc447499",
+ "dots_proxy.py": "2b74af2e175d12300196cab008062ba1098e12bc208d617e3fe63c23c2613390",
+ "shim.py": "c0c55c45fbd9126e860b56d0d0359c0c87f8b39ef5d4da4a9560522452ac8c65",
+ "tailscale-setup.sh": "58fff7a0efbfe43009c00cef3e3b831b04efd44a061ab03fd3784cbc8fc12504"
+ },
+ "package_version": "0.4.0-dev",
+ "schema_version": 1
+}
diff --git a/tailscale-setup.sh b/tailscale-setup.sh
index 78ad66b..08e8411 100755
--- a/tailscale-setup.sh
+++ b/tailscale-setup.sh
@@ -181,11 +181,28 @@ preflight() {
[[ "$port_start" -le 22 ]] || fail 'unprivileged port 22 unavailable'
}
install_all() {
+ local source_layout=source
+ [[ "$(basename -- "${BASH_SOURCE[0]}")" != setup.sh ]] || source_layout=installed
+ [[ -f "$SOURCE/dots-ts" ]] || fail 'missing companion dots-ts'
+ python3 "$SOURCE/dots-ts" _verify-package "$SOURCE" "$source_layout" "$SOURCE" || fail 'invalid source bundle; download all four files and source-manifest.json together'
require curl; require tar; require sha256sum
local shim_source="$SOURCE/shim.py"
- [[ -f "$shim_source" ]] || shim_source="$SOURCE/shim/shim.py"
+ [[ "$source_layout" != installed ]] || shim_source="$SOURCE/shim/shim.py"
[[ -f "$shim_source" ]] || fail "missing companion shim.py"
[[ -f "$SOURCE/dots-ts" && -f "$SOURCE/dots_proxy.py" ]] || fail 'missing companions: dots-ts and dots_proxy.py (download the complete source set)'
+ # Refuse all known code/metadata conflicts before placing any payload.
+ local source_name installed_name source_path
+ for source_name in tailscale-setup.sh shim.py dots-ts dots_proxy.py source-manifest.json; do
+ case "$source_name" in
+ tailscale-setup.sh) installed_name=setup.sh; source_path="${BASH_SOURCE[0]}";;
+ shim.py) installed_name=shim/shim.py; source_path="$shim_source";;
+ *) installed_name="$source_name"; source_path="$SOURCE/$source_name";;
+ esac
+ [[ ! -L "$D/$installed_name" ]] || fail "symlink refused: $D/$installed_name"
+ if [[ -e "$D/$installed_name" ]]; then
+ cmp -s "$source_path" "$D/$installed_name" || fail "existing file differs: $D/$installed_name (manual review required)"
+ fi
+ done
local tmp tgz url want got
tmp="$(mktemp -d "$R/install.XXXXXX")"
trap 'rm -rf -- "$tmp"' RETURN
@@ -249,6 +266,8 @@ export DOTS_TS_DIR
exec bash "$DOTS_TS_DIR/setup.sh" "${1:-start}"
START
place "$tmp/start.sh" "$D/start.sh" 700
+ python3 "$SOURCE/dots-ts" _verify-package "$D" installed "$SOURCE" || fail 'installed source verification failed'
+ place "$SOURCE/source-manifest.json" "$D/source-manifest.json" 600
printf 'persistent installation\n' > "$D/installed"
printf 'installed: %s\n' "$D"
}
diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py
index a26c909..1661489 100644
--- a/tests/test_cli_doctor.py
+++ b/tests/test_cli_doctor.py
@@ -1,5 +1,6 @@
"""Behavior checks for the installed CLI and shareable diagnostics."""
import contextlib
+import hashlib
import importlib.machinery
import importlib.util
import io
@@ -85,6 +86,10 @@ def populate(self):
path = self.directory / name
path.parent.mkdir(exist_ok=True, parents=True)
path.write_text("PRIVATE-SECRET-CONTENTS")
+ manifest = {"schema_version": 1, "package_version": "0.4.0-dev",
+ "files": {source: hashlib.sha256((self.directory / installed).read_bytes()).hexdigest()
+ for source, installed in tool.CORE_FILES.items()}}
+ (self.directory / tool.MANIFEST).write_text(json.dumps(manifest))
def diagnose(self, state="Running", *, result=None, error=None, target=None, processes=None):
self.populate()
@@ -212,8 +217,12 @@ def stat(path, *args, **kwargs):
def test_doctor_does_not_read_key_or_state_contents(self):
self.populate()
- with mock.patch.object(pathlib.Path, "read_bytes", side_effect=AssertionError("secret file read")), \
- mock.patch.object(pathlib.Path, "read_text", side_effect=AssertionError("secret file read")):
+ original = tool.bounded_code_read
+ allowed = {tool.MANIFEST} | set(tool.CORE_FILES.values())
+ def guarded(directory, relative, limit):
+ self.assertIn(relative, allowed)
+ return original(directory, relative, limit)
+ with mock.patch.object(tool, "bounded_code_read", side_effect=guarded):
self.diagnose()
def test_readonly_mount_overrides_optimistic_access_and_gives_context_advice(self):
diff --git a/tests/test_install_helpers.py b/tests/test_install_helpers.py
index 492c6d1..3c2dcc6 100644
--- a/tests/test_install_helpers.py
+++ b/tests/test_install_helpers.py
@@ -1,5 +1,7 @@
"""Exercise installer companion staging with inert pre-existing identity fixtures."""
import sys
+import hashlib
+import json
from pathlib import Path
import re
import shlex
@@ -38,7 +40,7 @@ def setUp(self):
functions = []
for name in ("private_dir", "place", "install_all"):
functions.append(re.search(r"^" + name + r"\(\) \{\n.*?^}\n", SOURCE, re.M | re.S).group())
- self.entry = root / "install-test.sh"
+ self.entry = self.source / "tailscale-setup.sh"
self.entry.write_text("set -euo pipefail\numask 077\n"
"fail() { printf '%s\\n' \"$*\" >&2; exit 1; }\n"
"require() { command -v \"$1\" >/dev/null; }\n" +
@@ -46,7 +48,11 @@ def setUp(self):
"\n".join(k + "=" + shlex.quote(str(v)) for k, v in
(("SOURCE", self.source), ("D", self.d),
("R", self.r), ("S", self.d / "sshd"))) +
- "\ninstall_all\n")
+ '\nSOURCE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"\ninstall_all\n')
+ files = ("tailscale-setup.sh", "shim.py", "dots-ts", "dots_proxy.py")
+ manifest = {"schema_version": 1, "package_version": "0.4.0-dev",
+ "files": {name: hashlib.sha256((self.source / name).read_bytes()).hexdigest() for name in files}}
+ (self.source / "source-manifest.json").write_text(json.dumps(manifest))
def install(self):
return subprocess.run(["bash", str(self.entry)], text=True, capture_output=True, timeout=10)
@@ -63,13 +69,15 @@ def test_install_places_executable_helpers_and_wrapper(self):
self.assertEqual((self.d / n).stat().st_mode & 0o777, 0o700)
self.assertIn('"$DOTS_TS_DIR/setup.sh"', (self.d / "start.sh").read_text())
self.assertFalse((self.d / "outbound-proxy.json").exists())
+ self.assertEqual((self.d / "source-manifest.json").read_bytes(),
+ (self.source / "source-manifest.json").read_bytes())
self.assert_identity_preserved()
def test_missing_companion_stops_without_install_marker(self):
(self.source / "dots_proxy.py").unlink()
r = self.install()
self.assertNotEqual(r.returncode, 0)
- self.assertIn("missing companions", r.stderr)
+ self.assertIn("invalid source bundle", r.stderr)
self.assertFalse((self.d / "installed").exists())
self.assert_identity_preserved()
@@ -81,6 +89,41 @@ def test_conflicting_helper_is_not_overwritten(self):
self.assertEqual((self.d / "dots-ts").read_text(), "existing custom helper\n")
self.assert_identity_preserved()
+ def test_modified_bundle_fails_before_any_code_is_placed(self):
+ (self.source / "shim.py").write_text("mixed release fixture")
+ result = self.install()
+ self.assertNotEqual(result.returncode, 0)
+ self.assertFalse((self.d / "dots-ts").exists())
+ self.assertFalse((self.d / "source-manifest.json").exists())
+ self.assertFalse((self.d / "installed").exists())
+ self.assert_identity_preserved()
+
+ def test_manifest_conflict_prevents_partial_payload_placement(self):
+ (self.d / "source-manifest.json").write_text("existing metadata")
+ result = self.install()
+ self.assertNotEqual(result.returncode, 0)
+ self.assertFalse((self.d / "shim/shim.py").exists())
+ self.assertFalse((self.d / "dots-ts").exists())
+ self.assertEqual((self.d / "source-manifest.json").read_text(), "existing metadata")
+ self.assert_identity_preserved()
+
+ def test_missing_metadata_rejects_new_install(self):
+ (self.source / "source-manifest.json").unlink()
+ result = self.install()
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("metadata_missing", result.stderr)
+ self.assertFalse((self.d / "installed").exists())
+ self.assert_identity_preserved()
+
+ def test_reinstall_uses_verified_installed_layout(self):
+ self.assertEqual(self.install().returncode, 0)
+ (self.d / "shim.py").write_text("unverified stray source file")
+ result = subprocess.run(["bash", str(self.d / "setup.sh")], text=True,
+ capture_output=True, timeout=10)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual((self.d / "shim/shim.py").read_bytes(), (self.source / "shim.py").read_bytes())
+ self.assert_identity_preserved()
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_package_version.py b/tests/test_package_version.py
new file mode 100644
index 0000000..dbea6b1
--- /dev/null
+++ b/tests/test_package_version.py
@@ -0,0 +1,144 @@
+"""Offline package identity, mixed-source detection and bounded local version checks."""
+import contextlib
+import hashlib
+import importlib.machinery
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+ROOT = Path(__file__).resolve().parents[1]
+loader = importlib.machinery.SourceFileLoader("dots_ts_version_test", str(ROOT / "dots-ts"))
+spec = importlib.util.spec_from_loader(loader.name, loader)
+tool = importlib.util.module_from_spec(spec)
+loader.exec_module(tool)
+
+
+class PackageVersionTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory(prefix="package-version-")
+ self.addCleanup(self.temp.cleanup)
+ self.directory = Path(self.temp.name)
+ self.metadata = {"schema_version": 1, "package_version": "0.4.0-dev", "files": {}}
+ for source, installed in tool.CORE_FILES.items():
+ path = self.directory / installed
+ path.parent.mkdir(parents=True, exist_ok=True)
+ payload = ("fixture for " + source).encode()
+ path.write_bytes(payload)
+ self.metadata["files"][source] = hashlib.sha256(payload).hexdigest()
+ self.write_manifest()
+
+ def write_manifest(self):
+ (self.directory / tool.MANIFEST).write_text(json.dumps(self.metadata))
+
+ def test_matching_package_and_unknown_revision(self):
+ report = tool.package_report(self.directory)
+ self.assertEqual(report["integrity"], "match")
+ self.assertEqual(report["declared_version"], "0.4.0-dev")
+ self.assertIsNone(report["source_revision"])
+
+ def test_modified_and_mixed_sources_do_not_claim_match(self):
+ for payload in (b"local customization", (ROOT / "dots_proxy.py").read_bytes()):
+ (self.directory / "dots_proxy.py").write_bytes(payload)
+ report = tool.package_report(self.directory)
+ self.assertEqual(report["integrity"], "mismatch")
+ self.assertEqual(report["reason"], "modified_or_mixed")
+ self.assertEqual(report["files"]["dots_proxy.py"], "mismatch")
+
+ def test_missing_core_and_legacy_metadata(self):
+ (self.directory / "dots-ts").unlink()
+ self.assertEqual(tool.package_report(self.directory)["integrity"], "incomplete")
+ (self.directory / tool.MANIFEST).unlink()
+ report = tool.package_report(self.directory)
+ self.assertEqual(report["integrity"], "unknown")
+ self.assertEqual(report["reason"], "metadata_missing")
+ self.assertIsNone(report["declared_version"])
+
+ def test_code_and_manifest_restore_together_after_update(self):
+ path = self.directory / "dots_proxy.py"
+ old_code = path.read_bytes()
+ old_manifest = (self.directory / tool.MANIFEST).read_bytes()
+ path.write_bytes(b"next release fixture")
+ self.assertEqual(tool.package_report(self.directory)["integrity"], "mismatch")
+ self.metadata["files"]["dots_proxy.py"] = hashlib.sha256(path.read_bytes()).hexdigest()
+ self.metadata["package_version"] = "0.4.1"
+ self.write_manifest()
+ self.assertEqual(tool.package_report(self.directory)["integrity"], "match")
+ path.write_bytes(old_code)
+ self.assertEqual(tool.package_report(self.directory)["integrity"], "mismatch")
+ (self.directory / tool.MANIFEST).write_bytes(old_manifest)
+ self.assertEqual(tool.package_report(self.directory)["integrity"], "match")
+ self.assertEqual(tool.package_report(self.directory)["declared_version"], "0.4.0-dev")
+
+ def test_manifest_rejects_bad_schema_paths_duplicates_and_oversize(self):
+ original = json.dumps(self.metadata)
+ for raw in ('{"schema_version":1,"schema_version":1}', '{', '[]',
+ original.replace('"schema_version": 1', '"schema_version": true'),
+ original.replace('"dots-ts":', '"state/tailscaled.state":'),
+ original.replace('0.4.0-dev', 'PRIVATE\\nTOKEN'), ' ' * 16385):
+ (self.directory / tool.MANIFEST).write_text(raw)
+ report = tool.package_report(self.directory)
+ self.assertEqual(report["integrity"], "unknown")
+ self.assertIsNone(report["declared_version"])
+ self.assertNotIn("PRIVATE", json.dumps(report))
+
+ def test_symlinked_core_parent_and_manifest_are_not_followed(self):
+ shim = self.directory / "shim"
+ shim.rename(self.directory / "elsewhere")
+ shim.symlink_to(self.directory / "elsewhere", target_is_directory=True)
+ self.assertEqual(tool.package_report(self.directory)["files"]["shim.py"], "unavailable")
+ manifest = self.directory / tool.MANIFEST
+ manifest.rename(self.directory / "other.json")
+ manifest.symlink_to(self.directory / "other.json")
+ self.assertEqual(tool.package_report(self.directory)["reason"], "metadata_unavailable")
+
+ def test_fifo_cannot_block_core_read(self):
+ path = self.directory / "dots-ts"
+ path.unlink()
+ os.mkfifo(path)
+ self.assertEqual(tool.package_report(self.directory)["files"]["dots-ts"], "unavailable")
+
+ def test_source_layout_and_checked_in_manifest(self):
+ self.assertEqual(tool.package_report(ROOT, "source")["integrity"], "match")
+ subprocess.run([sys.executable, str(ROOT / "scripts/source_manifest.py"), "--check"], check=True)
+
+ def binary(self, body):
+ path = self.directory / "tailscale"
+ path.write_text("#!" + sys.executable + "\n" + body)
+ path.chmod(0o700)
+
+ def test_local_tailscale_is_separate_and_output_bounded(self):
+ self.binary("print('1.102.4')\nprint('long build description')\n")
+ self.assertEqual(tool.tailscale_version(self.directory), {"version": "1.102.4", "status": "available"})
+ self.binary("print('x' * 10000)\n")
+ self.assertEqual(tool.tailscale_version(self.directory)["status"], "invalid_response")
+ self.binary("import time\ntime.sleep(10)\n")
+ with mock.patch.object(tool, "MANAGEMENT_TIMEOUT", .05):
+ self.assertEqual(tool.tailscale_version(self.directory)["status"], "timeout")
+ (self.directory / "tailscale").unlink()
+ self.assertEqual(tool.tailscale_version(self.directory)["status"], "unavailable")
+
+ def test_json_exit_codes_and_no_network_or_daemon(self):
+ for expected_code, mutation in ((0, None), (1, "modify"), (0, "legacy")):
+ if mutation == "modify":
+ (self.directory / "dots-ts").write_text("mixed")
+ elif mutation == "legacy":
+ (self.directory / tool.MANIFEST).unlink()
+ output = io.StringIO()
+ with mock.patch.object(tool, "directories", return_value=(self.directory, self.directory)), \
+ mock.patch.object(tool, "run_cli", side_effect=AssertionError("daemon query")), \
+ contextlib.redirect_stdout(output):
+ self.assertEqual(tool.main(["version", "--json"]), expected_code)
+ report = json.loads(output.getvalue())
+ self.assertEqual(report["tailscale"]["status"], "unavailable")
+ self.assertNotIn(str(self.directory), output.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()