twin-update 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- twin_update-0.2.0/CHANGELOG.md +34 -0
- twin_update-0.2.0/LICENSE +202 -0
- twin_update-0.2.0/MANIFEST.in +3 -0
- twin_update-0.2.0/PKG-INFO +202 -0
- twin_update-0.2.0/README.md +173 -0
- twin_update-0.2.0/SECURITY.md +51 -0
- twin_update-0.2.0/config.example.toml +46 -0
- twin_update-0.2.0/pyproject.toml +41 -0
- twin_update-0.2.0/setup.cfg +4 -0
- twin_update-0.2.0/src/twin_update/__init__.py +3 -0
- twin_update-0.2.0/src/twin_update/__main__.py +3 -0
- twin_update-0.2.0/src/twin_update/cli.py +255 -0
- twin_update-0.2.0/src/twin_update/config.py +162 -0
- twin_update-0.2.0/src/twin_update/engine.py +759 -0
- twin_update-0.2.0/src/twin_update/orchestrator.py +338 -0
- twin_update-0.2.0/src/twin_update/peer.py +121 -0
- twin_update-0.2.0/src/twin_update/system.py +248 -0
- twin_update-0.2.0/src/twin_update.egg-info/PKG-INFO +202 -0
- twin_update-0.2.0/src/twin_update.egg-info/SOURCES.txt +23 -0
- twin_update-0.2.0/src/twin_update.egg-info/dependency_links.txt +1 -0
- twin_update-0.2.0/src/twin_update.egg-info/entry_points.txt +2 -0
- twin_update-0.2.0/src/twin_update.egg-info/top_level.txt +1 -0
- twin_update-0.2.0/tests/fakes.py +339 -0
- twin_update-0.2.0/tests/test_twin_update.py +595 -0
- twin_update-0.2.0/tools/build_zipapp.py +19 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-10-09)
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
* Fixed: `doctor`, `holds` and `unhold` printed nothing without `--json`
|
|
8
|
+
(also when stdout is not a terminal). Every command now prints plain text;
|
|
9
|
+
anything without a formatter falls back to the JSON report.
|
|
10
|
+
* Machines can use the same user name: the config's `machine_id_sha256`
|
|
11
|
+
decides which table is "this machine".
|
|
12
|
+
* Machine labels are fully user-defined; docs and examples use generic labels.
|
|
13
|
+
* README: security model summary and generic Tailscale policy guidance.
|
|
14
|
+
* Packaging metadata for PyPI (classifiers, project URLs).
|
|
15
|
+
|
|
16
|
+
## 0.1.0 (2026-10-09, not published)
|
|
17
|
+
|
|
18
|
+
First version.
|
|
19
|
+
|
|
20
|
+
* `status`, `run`, `rollback`, `holds`, `unhold`, `doctor`, `local --serve`.
|
|
21
|
+
* Two machines, one run: identity check, lock files held, sudo asked once,
|
|
22
|
+
same pinned version on both or skipped on both.
|
|
23
|
+
* Graceful close only (pidfd SIGTERM); a close timeout aborts on both machines
|
|
24
|
+
before any update, and the apps it closed are reopened.
|
|
25
|
+
* Verified rollback copy captured before every update; exactly one kept per app,
|
|
26
|
+
older ones pruned after verification plus a grace period.
|
|
27
|
+
* Holds not set by Twin Update are cleared unless `keep_holds` lists them.
|
|
28
|
+
* Launch smoke test, per-run JSON report (0600, labels only), desktop notification.
|
|
29
|
+
* Single-file zipapp build (compressed); standard library only.
|
|
30
|
+
* Rollback copies of vendor debs without an md5sums member are verified by
|
|
31
|
+
hashing the deb's files against dpkg's record; a mismatch is refused, also
|
|
32
|
+
in a dry run.
|
|
33
|
+
* Notification falls back to `gdbus` when `notify-send` is not installed and
|
|
34
|
+
soft-fails when neither is; `doctor` reports `notify_tool`.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: twin-update
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Close, update and verify the same apt-packaged desktop apps on two Linux machines in one run, with a verified one-command rollback.
|
|
5
|
+
Author: Dragon Lady
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/Dragon-Lady/twin-update
|
|
8
|
+
Project-URL: Source, https://github.com/Dragon-Lady/twin-update
|
|
9
|
+
Project-URL: Issues, https://github.com/Dragon-Lady/twin-update/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/Dragon-Lady/twin-update/blob/main/CHANGELOG.md
|
|
11
|
+
Project-URL: Security, https://github.com/Dragon-Lady/twin-update/security/policy
|
|
12
|
+
Keywords: apt,updates,rollback,desktop,linux,tailscale
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Intended Audience :: System Administrators
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: System :: Installation/Setup
|
|
24
|
+
Classifier: Topic :: System :: Systems Administration
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# Twin Update
|
|
31
|
+
|
|
32
|
+
Update the same desktop apps on two Linux machines in **one run**, keep a
|
|
33
|
+
verified rollback copy of what was installed, and roll back with **one command**.
|
|
34
|
+
|
|
35
|
+
It targets Debian/Ubuntu-family desktops (tested on Ubuntu 24.04-based
|
|
36
|
+
systems with Python 3.12; needs Python 3.11+, systemd user sessions and apt)
|
|
37
|
+
and apps installed as **apt packages from their vendor repos**. Built-in
|
|
38
|
+
entries: Cursor (`cursor`), ChatGPT desktop (`chatgpt`) and Grok Bot desktop
|
|
39
|
+
(`grok-bot`). Other apt-packaged apps can be added in the config. Snaps,
|
|
40
|
+
Flatpaks, AppImages and CLIs are out of scope.
|
|
41
|
+
|
|
42
|
+
Machine labels are whatever you name them in the config; the examples below
|
|
43
|
+
use `desktop-a` and `laptop-b`.
|
|
44
|
+
|
|
45
|
+
## Security model, in short
|
|
46
|
+
|
|
47
|
+
* **Two machines you control, one trusted link.** The machine you start the
|
|
48
|
+
run on reaches the other over `ssh` (BatchMode, host keys checked):
|
|
49
|
+
Tailscale SSH or an ordinary ssh key. Without that link only
|
|
50
|
+
`--local-only --dry-run` works.
|
|
51
|
+
* **sudo is asked once per run** and checked on both machines. The password
|
|
52
|
+
goes only to `sudo -S` on stdin (over the run's ssh session for the other
|
|
53
|
+
machine); never argv, environment, files, logs or reports. See
|
|
54
|
+
[SECURITY.md](SECURITY.md).
|
|
55
|
+
* **apt only.** Root runs only `apt-get update`, `apt-get install` of a pinned
|
|
56
|
+
version or of a stored, sha256-checked `.deb`, and `apt-mark hold/unhold`.
|
|
57
|
+
Each machine installs from its own configured apt sources.
|
|
58
|
+
* **Graceful closes only.** SIGTERM, never SIGKILL. If an app does not close
|
|
59
|
+
in time, the run **aborts on both machines before anything is updated**.
|
|
60
|
+
* **Rollback copy before every update**, verified against what dpkg installed;
|
|
61
|
+
no verified copy on either machine means the app is skipped on both.
|
|
62
|
+
* **Nothing resident.** No daemon, timer or listener; apps close only inside a
|
|
63
|
+
run you start.
|
|
64
|
+
* It restores program files, not app data (see below).
|
|
65
|
+
|
|
66
|
+
## What a run does
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
twin-update run [--apps cursor,chatgpt] [--dry-run]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
1. **Preflight on both machines**: identity (`whoami`, optional machine-id) must
|
|
73
|
+
match the config; apt/dpkg must be idle; every `lock_paths` file is locked
|
|
74
|
+
for the whole run, so a sync or backup job cannot run during the update.
|
|
75
|
+
Asks for the sudo password **once** and checks it on both machines, then
|
|
76
|
+
`apt-get update` on both.
|
|
77
|
+
2. **Plan**: an app is updated only if both machines have it installed, are
|
|
78
|
+
offered the **same** candidate version, and no kept hold blocks it. Otherwise
|
|
79
|
+
it is skipped on both, with the reason. The exact version is pinned.
|
|
80
|
+
3. **Rollback copy first**: the installed `.deb` is stored in
|
|
81
|
+
`~/.local/share/twin-update/rollback/<app>/<version>/` with a sha256, taken
|
|
82
|
+
from the apt cache, `apt-get download <pkg>=<installed>`, or a matching local
|
|
83
|
+
`.deb`. It is checked against dpkg's md5sums of the installed build (for
|
|
84
|
+
vendor debs without an md5sums member, the deb's files are hashed in a
|
|
85
|
+
stream and compared). A mismatch is refused. No copy on either machine =
|
|
86
|
+
the app is skipped on both.
|
|
87
|
+
4. **Holds**: holds on these apps that Twin Update did not set are cleared (one
|
|
88
|
+
report line each) unless `keep_holds` lists them. Holds on other packages
|
|
89
|
+
are only reported.
|
|
90
|
+
5. **Graceful close on both**: SIGTERM (via pidfd) to the main processes of the
|
|
91
|
+
app's process tree, found by executable path and the package's file list.
|
|
92
|
+
No force-kill. If anything is still running after `close_s`, the run
|
|
93
|
+
**aborts on both machines before any update**, names the app and process,
|
|
94
|
+
and reopens what it closed.
|
|
95
|
+
6. **Update** each machine from its own repo: `apt-get install --only-upgrade
|
|
96
|
+
<pkg>=<version>`. Nothing is copied between machines.
|
|
97
|
+
7. **Verify**: dpkg version, `dpkg -V`, and a launch smoke test in the graphical
|
|
98
|
+
session (`systemd-run --user`): stays up `smoke_s` seconds, no crash in the
|
|
99
|
+
journal, closes gracefully. On failure you are offered a rollback.
|
|
100
|
+
8. **Reopen** only the apps that were open before.
|
|
101
|
+
9. **Prune**: exactly one rollback copy per app is kept (the version before the
|
|
102
|
+
latest update). Older copies are removed only after the new version passed
|
|
103
|
+
its checks **and** a grace period (7 days or 2 good launches).
|
|
104
|
+
10. **Report**: `~/.local/state/twin-update/runs/<ts>-run.json` (0600, machine
|
|
105
|
+
labels only) on both machines, plus a desktop notification such as
|
|
106
|
+
`Cursor laptop-b 2.4.1→2.5.0 ✓` (via `notify-send`, or `gdbus`
|
|
107
|
+
when `notify-send` is not installed).
|
|
108
|
+
|
|
109
|
+
Apps are only ever closed inside a run you start. There is no daemon, timer,
|
|
110
|
+
watcher or listener; the ssh session to the other machine exists only for the run.
|
|
111
|
+
The run moves itself into its own `systemd-run --user --scope`, so closing the
|
|
112
|
+
app whose terminal launched it does not stop it.
|
|
113
|
+
|
|
114
|
+
## Other commands
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
twin-update status [--local-only] # per machine/app: installed, candidate, running, rollback copy, hold
|
|
118
|
+
twin-update rollback <app> [--machine <label>|both] [--dry-run] [--no-hold]
|
|
119
|
+
twin-update holds
|
|
120
|
+
twin-update unhold <app> [--machine ...] [--dry-run]
|
|
121
|
+
twin-update doctor # identity, sudo group, apt idle, locks, session, ssh
|
|
122
|
+
twin-update local --serve # per-machine engine; the peer runs this over ssh
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`rollback` checks the stored copy's sha256, closes the app gracefully, installs
|
|
126
|
+
it with `apt-get install --allow-downgrades ./<deb>`, sets `apt-mark hold`
|
|
127
|
+
(recorded as Twin Update's own hold; the next deliberate `run` releases it),
|
|
128
|
+
verifies, and reopens the app if it was open.
|
|
129
|
+
|
|
130
|
+
A rollback restores **program files, not app data**. If a new version migrated
|
|
131
|
+
its settings or databases, keep your own data backups. Twin Update never opens,
|
|
132
|
+
copies or modifies app databases.
|
|
133
|
+
|
|
134
|
+
## Install (each machine)
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
pipx install twin-update # or: uv tool install twin-update
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
or the single-file zipapp from a release:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
install -D -m 0755 twin-update.pyz ~/.local/bin/twin-update
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Install it at the same path on both machines (`remote_command` in the config
|
|
147
|
+
points to it). Then create `~/.config/twin-update/config.toml` (mode 0600)
|
|
148
|
+
from `config.example.toml`. The same file works on both machines. Check with
|
|
149
|
+
`twin-update doctor`, then `twin-update run --dry-run`.
|
|
150
|
+
|
|
151
|
+
### Transport
|
|
152
|
+
|
|
153
|
+
The machine you run on reaches the other with plain `ssh` (BatchMode, host
|
|
154
|
+
keys checked, no password prompts). The remote side runs
|
|
155
|
+
`twin-update local --serve` for the length of the run. Two options:
|
|
156
|
+
|
|
157
|
+
**Tailscale SSH.** On each machine that should accept runs:
|
|
158
|
+
`sudo tailscale set --ssh`. "Shields up" blocks incoming connections, Tailscale
|
|
159
|
+
SSH included, so it must be off on those machines; let the policy do the
|
|
160
|
+
limiting instead. Example policy fragment with tags (merge it into your
|
|
161
|
+
policy; tagging a device makes it tag-owned rather than user-owned):
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"tagOwners": {
|
|
166
|
+
"tag:twin-a": ["autogroup:admin"],
|
|
167
|
+
"tag:twin-b": ["autogroup:admin"]
|
|
168
|
+
},
|
|
169
|
+
"grants": [
|
|
170
|
+
{ "src": ["tag:twin-a"], "dst": ["tag:twin-b"], "ip": ["tcp:22"] },
|
|
171
|
+
{ "src": ["tag:twin-b"], "dst": ["tag:twin-a"], "ip": ["tcp:22"] }
|
|
172
|
+
],
|
|
173
|
+
"ssh": [
|
|
174
|
+
{ "action": "accept", "src": ["tag:twin-a"], "dst": ["tag:twin-b"], "users": ["alice"] },
|
|
175
|
+
{ "action": "accept", "src": ["tag:twin-b"], "dst": ["tag:twin-a"], "users": ["alice"] }
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Use `"action": "accept"`: `"check"` needs an interactive browser login and a
|
|
181
|
+
BatchMode run cannot answer it. If your policy still has an allow-all rule,
|
|
182
|
+
it also lets everything else in the tailnet reach these machines; narrow it.
|
|
183
|
+
Keep only the direction you need if you always start runs on the same machine.
|
|
184
|
+
|
|
185
|
+
**Plain ssh key.** A dedicated key without a passphrase prompt (or one held by
|
|
186
|
+
your agent) in the other machine's `authorized_keys`, ideally limited with
|
|
187
|
+
`from="<the other machine's address>"`, and its host key already in
|
|
188
|
+
`known_hosts`.
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
PYTHONPATH=src:tests python3 -m unittest discover -s tests
|
|
194
|
+
python3 tools/build_zipapp.py # -> dist/twin-update.pyz
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Standard library only. Exit codes: 0 ok / dry-run, 1 failed, 2 usage or config,
|
|
198
|
+
3 aborted (nothing changed).
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
Apache License 2.0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Twin Update
|
|
2
|
+
|
|
3
|
+
Update the same desktop apps on two Linux machines in **one run**, keep a
|
|
4
|
+
verified rollback copy of what was installed, and roll back with **one command**.
|
|
5
|
+
|
|
6
|
+
It targets Debian/Ubuntu-family desktops (tested on Ubuntu 24.04-based
|
|
7
|
+
systems with Python 3.12; needs Python 3.11+, systemd user sessions and apt)
|
|
8
|
+
and apps installed as **apt packages from their vendor repos**. Built-in
|
|
9
|
+
entries: Cursor (`cursor`), ChatGPT desktop (`chatgpt`) and Grok Bot desktop
|
|
10
|
+
(`grok-bot`). Other apt-packaged apps can be added in the config. Snaps,
|
|
11
|
+
Flatpaks, AppImages and CLIs are out of scope.
|
|
12
|
+
|
|
13
|
+
Machine labels are whatever you name them in the config; the examples below
|
|
14
|
+
use `desktop-a` and `laptop-b`.
|
|
15
|
+
|
|
16
|
+
## Security model, in short
|
|
17
|
+
|
|
18
|
+
* **Two machines you control, one trusted link.** The machine you start the
|
|
19
|
+
run on reaches the other over `ssh` (BatchMode, host keys checked):
|
|
20
|
+
Tailscale SSH or an ordinary ssh key. Without that link only
|
|
21
|
+
`--local-only --dry-run` works.
|
|
22
|
+
* **sudo is asked once per run** and checked on both machines. The password
|
|
23
|
+
goes only to `sudo -S` on stdin (over the run's ssh session for the other
|
|
24
|
+
machine); never argv, environment, files, logs or reports. See
|
|
25
|
+
[SECURITY.md](SECURITY.md).
|
|
26
|
+
* **apt only.** Root runs only `apt-get update`, `apt-get install` of a pinned
|
|
27
|
+
version or of a stored, sha256-checked `.deb`, and `apt-mark hold/unhold`.
|
|
28
|
+
Each machine installs from its own configured apt sources.
|
|
29
|
+
* **Graceful closes only.** SIGTERM, never SIGKILL. If an app does not close
|
|
30
|
+
in time, the run **aborts on both machines before anything is updated**.
|
|
31
|
+
* **Rollback copy before every update**, verified against what dpkg installed;
|
|
32
|
+
no verified copy on either machine means the app is skipped on both.
|
|
33
|
+
* **Nothing resident.** No daemon, timer or listener; apps close only inside a
|
|
34
|
+
run you start.
|
|
35
|
+
* It restores program files, not app data (see below).
|
|
36
|
+
|
|
37
|
+
## What a run does
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
twin-update run [--apps cursor,chatgpt] [--dry-run]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
1. **Preflight on both machines**: identity (`whoami`, optional machine-id) must
|
|
44
|
+
match the config; apt/dpkg must be idle; every `lock_paths` file is locked
|
|
45
|
+
for the whole run, so a sync or backup job cannot run during the update.
|
|
46
|
+
Asks for the sudo password **once** and checks it on both machines, then
|
|
47
|
+
`apt-get update` on both.
|
|
48
|
+
2. **Plan**: an app is updated only if both machines have it installed, are
|
|
49
|
+
offered the **same** candidate version, and no kept hold blocks it. Otherwise
|
|
50
|
+
it is skipped on both, with the reason. The exact version is pinned.
|
|
51
|
+
3. **Rollback copy first**: the installed `.deb` is stored in
|
|
52
|
+
`~/.local/share/twin-update/rollback/<app>/<version>/` with a sha256, taken
|
|
53
|
+
from the apt cache, `apt-get download <pkg>=<installed>`, or a matching local
|
|
54
|
+
`.deb`. It is checked against dpkg's md5sums of the installed build (for
|
|
55
|
+
vendor debs without an md5sums member, the deb's files are hashed in a
|
|
56
|
+
stream and compared). A mismatch is refused. No copy on either machine =
|
|
57
|
+
the app is skipped on both.
|
|
58
|
+
4. **Holds**: holds on these apps that Twin Update did not set are cleared (one
|
|
59
|
+
report line each) unless `keep_holds` lists them. Holds on other packages
|
|
60
|
+
are only reported.
|
|
61
|
+
5. **Graceful close on both**: SIGTERM (via pidfd) to the main processes of the
|
|
62
|
+
app's process tree, found by executable path and the package's file list.
|
|
63
|
+
No force-kill. If anything is still running after `close_s`, the run
|
|
64
|
+
**aborts on both machines before any update**, names the app and process,
|
|
65
|
+
and reopens what it closed.
|
|
66
|
+
6. **Update** each machine from its own repo: `apt-get install --only-upgrade
|
|
67
|
+
<pkg>=<version>`. Nothing is copied between machines.
|
|
68
|
+
7. **Verify**: dpkg version, `dpkg -V`, and a launch smoke test in the graphical
|
|
69
|
+
session (`systemd-run --user`): stays up `smoke_s` seconds, no crash in the
|
|
70
|
+
journal, closes gracefully. On failure you are offered a rollback.
|
|
71
|
+
8. **Reopen** only the apps that were open before.
|
|
72
|
+
9. **Prune**: exactly one rollback copy per app is kept (the version before the
|
|
73
|
+
latest update). Older copies are removed only after the new version passed
|
|
74
|
+
its checks **and** a grace period (7 days or 2 good launches).
|
|
75
|
+
10. **Report**: `~/.local/state/twin-update/runs/<ts>-run.json` (0600, machine
|
|
76
|
+
labels only) on both machines, plus a desktop notification such as
|
|
77
|
+
`Cursor laptop-b 2.4.1→2.5.0 ✓` (via `notify-send`, or `gdbus`
|
|
78
|
+
when `notify-send` is not installed).
|
|
79
|
+
|
|
80
|
+
Apps are only ever closed inside a run you start. There is no daemon, timer,
|
|
81
|
+
watcher or listener; the ssh session to the other machine exists only for the run.
|
|
82
|
+
The run moves itself into its own `systemd-run --user --scope`, so closing the
|
|
83
|
+
app whose terminal launched it does not stop it.
|
|
84
|
+
|
|
85
|
+
## Other commands
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
twin-update status [--local-only] # per machine/app: installed, candidate, running, rollback copy, hold
|
|
89
|
+
twin-update rollback <app> [--machine <label>|both] [--dry-run] [--no-hold]
|
|
90
|
+
twin-update holds
|
|
91
|
+
twin-update unhold <app> [--machine ...] [--dry-run]
|
|
92
|
+
twin-update doctor # identity, sudo group, apt idle, locks, session, ssh
|
|
93
|
+
twin-update local --serve # per-machine engine; the peer runs this over ssh
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`rollback` checks the stored copy's sha256, closes the app gracefully, installs
|
|
97
|
+
it with `apt-get install --allow-downgrades ./<deb>`, sets `apt-mark hold`
|
|
98
|
+
(recorded as Twin Update's own hold; the next deliberate `run` releases it),
|
|
99
|
+
verifies, and reopens the app if it was open.
|
|
100
|
+
|
|
101
|
+
A rollback restores **program files, not app data**. If a new version migrated
|
|
102
|
+
its settings or databases, keep your own data backups. Twin Update never opens,
|
|
103
|
+
copies or modifies app databases.
|
|
104
|
+
|
|
105
|
+
## Install (each machine)
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
pipx install twin-update # or: uv tool install twin-update
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
or the single-file zipapp from a release:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
install -D -m 0755 twin-update.pyz ~/.local/bin/twin-update
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Install it at the same path on both machines (`remote_command` in the config
|
|
118
|
+
points to it). Then create `~/.config/twin-update/config.toml` (mode 0600)
|
|
119
|
+
from `config.example.toml`. The same file works on both machines. Check with
|
|
120
|
+
`twin-update doctor`, then `twin-update run --dry-run`.
|
|
121
|
+
|
|
122
|
+
### Transport
|
|
123
|
+
|
|
124
|
+
The machine you run on reaches the other with plain `ssh` (BatchMode, host
|
|
125
|
+
keys checked, no password prompts). The remote side runs
|
|
126
|
+
`twin-update local --serve` for the length of the run. Two options:
|
|
127
|
+
|
|
128
|
+
**Tailscale SSH.** On each machine that should accept runs:
|
|
129
|
+
`sudo tailscale set --ssh`. "Shields up" blocks incoming connections, Tailscale
|
|
130
|
+
SSH included, so it must be off on those machines; let the policy do the
|
|
131
|
+
limiting instead. Example policy fragment with tags (merge it into your
|
|
132
|
+
policy; tagging a device makes it tag-owned rather than user-owned):
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"tagOwners": {
|
|
137
|
+
"tag:twin-a": ["autogroup:admin"],
|
|
138
|
+
"tag:twin-b": ["autogroup:admin"]
|
|
139
|
+
},
|
|
140
|
+
"grants": [
|
|
141
|
+
{ "src": ["tag:twin-a"], "dst": ["tag:twin-b"], "ip": ["tcp:22"] },
|
|
142
|
+
{ "src": ["tag:twin-b"], "dst": ["tag:twin-a"], "ip": ["tcp:22"] }
|
|
143
|
+
],
|
|
144
|
+
"ssh": [
|
|
145
|
+
{ "action": "accept", "src": ["tag:twin-a"], "dst": ["tag:twin-b"], "users": ["alice"] },
|
|
146
|
+
{ "action": "accept", "src": ["tag:twin-b"], "dst": ["tag:twin-a"], "users": ["alice"] }
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Use `"action": "accept"`: `"check"` needs an interactive browser login and a
|
|
152
|
+
BatchMode run cannot answer it. If your policy still has an allow-all rule,
|
|
153
|
+
it also lets everything else in the tailnet reach these machines; narrow it.
|
|
154
|
+
Keep only the direction you need if you always start runs on the same machine.
|
|
155
|
+
|
|
156
|
+
**Plain ssh key.** A dedicated key without a passphrase prompt (or one held by
|
|
157
|
+
your agent) in the other machine's `authorized_keys`, ideally limited with
|
|
158
|
+
`from="<the other machine's address>"`, and its host key already in
|
|
159
|
+
`known_hosts`.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
PYTHONPATH=src:tests python3 -m unittest discover -s tests
|
|
165
|
+
python3 tools/build_zipapp.py # -> dist/twin-update.pyz
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Standard library only. Exit codes: 0 ok / dry-run, 1 failed, 2 usage or config,
|
|
169
|
+
3 aborted (nothing changed).
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
Apache License 2.0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Security notes
|
|
2
|
+
|
|
3
|
+
## sudo password
|
|
4
|
+
|
|
5
|
+
* Asked **once** per run with `getpass` (no echo).
|
|
6
|
+
* Locally it is written only to the stdin of `sudo -k -S -p '' -- <command>`.
|
|
7
|
+
`-k` makes sudo ignore cached credentials so it always consumes exactly that
|
|
8
|
+
line; nothing left over reaches the command's stdin.
|
|
9
|
+
* For the other machine it travels **inside the run's ssh session** as one JSON
|
|
10
|
+
line on the remote `twin-update local --serve` stdin, and is used the same way
|
|
11
|
+
there.
|
|
12
|
+
* It is never placed in argv, environment variables, files, reports or logs.
|
|
13
|
+
The serve loop never echoes request arguments. Both engines keep it in a
|
|
14
|
+
`bytearray` and overwrite it with zeros when the run ends (Python may still
|
|
15
|
+
hold transient copies until garbage collection; that is a known limit).
|
|
16
|
+
* The test suite asserts that the password appears in no argv, env, log line,
|
|
17
|
+
report or file, and only on sudo's stdin.
|
|
18
|
+
|
|
19
|
+
## What runs as root
|
|
20
|
+
|
|
21
|
+
Only: `true` (password check), `apt-get update`, `apt-get install
|
|
22
|
+
--only-upgrade <pkg>=<version>`, `apt-get install --allow-downgrades <stored
|
|
23
|
+
deb>` (after its sha256 check), `apt-mark hold|unhold <pkg>`.
|
|
24
|
+
|
|
25
|
+
## Processes
|
|
26
|
+
|
|
27
|
+
Apps are closed only with SIGTERM through a pidfd after re-checking the
|
|
28
|
+
process start time (no PID-reuse races). Twin Update never sends SIGKILL; a
|
|
29
|
+
close timeout aborts the run on both machines before any update.
|
|
30
|
+
|
|
31
|
+
## Network
|
|
32
|
+
|
|
33
|
+
No listener, daemon or timer. The ssh session (BatchMode, host keys checked)
|
|
34
|
+
exists only while a command runs.
|
|
35
|
+
|
|
36
|
+
## Trust boundaries
|
|
37
|
+
|
|
38
|
+
* Both machines must be yours. The sudo password typed on one machine is used
|
|
39
|
+
on the other, so the other machine and the ssh link to it must be trusted
|
|
40
|
+
as much as the one you type on.
|
|
41
|
+
* Anyone who can already ssh in as your user on either machine could run
|
|
42
|
+
Twin Update there, but still needs the sudo password for every root step.
|
|
43
|
+
* Apps come from each machine's own apt sources and their signing keys; Twin
|
|
44
|
+
Update does not add repositories or keys and does not copy packages between
|
|
45
|
+
machines.
|
|
46
|
+
* Rollback copies are stored per user (0700 directories, 0600 files) with a
|
|
47
|
+
sha256 that is checked before any rollback install.
|
|
48
|
+
|
|
49
|
+
## Reporting
|
|
50
|
+
|
|
51
|
+
Please report issues privately through the repository's security advisories.
|