shadow-sentinel 1.0.1__py3-none-any.whl
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.
- shadow_sentinel/__init__.py +7 -0
- shadow_sentinel/core.py +926 -0
- shadow_sentinel-1.0.1.dist-info/METADATA +150 -0
- shadow_sentinel-1.0.1.dist-info/RECORD +8 -0
- shadow_sentinel-1.0.1.dist-info/WHEEL +5 -0
- shadow_sentinel-1.0.1.dist-info/entry_points.txt +2 -0
- shadow_sentinel-1.0.1.dist-info/licenses/LICENSE +18 -0
- shadow_sentinel-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shadow Sentinel - Autonomous OS-Level Revision & Backup Daemon
|
|
3
|
+
"""
|
|
4
|
+
__version__ = "1.0.1"
|
|
5
|
+
__author__ = "automatician (Torsten Frieser)"
|
|
6
|
+
__copyright__ = "Copyright (c) 2026 automatician (Torsten Frieser), Germany / Saxony / Zittau"
|
|
7
|
+
__license__ = "CC-BY-SA-3.0"
|
shadow_sentinel/core.py
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ==============================================================================
|
|
3
|
+
# SPDX-License-Identifier: CC-BY-SA-3.0
|
|
4
|
+
#
|
|
5
|
+
# Shadow Sentinel - Autonomous OS-Level Revision & Backup Guard
|
|
6
|
+
#
|
|
7
|
+
# Copyright (c) 2026 automatician (Torsten Frieser), Germany / Saxony / Zittau.
|
|
8
|
+
# All rights reserved.
|
|
9
|
+
#
|
|
10
|
+
# Licensed under the Creative Commons Attribution-ShareAlike 3.0 Germany
|
|
11
|
+
# (CC BY-SA 3.0 DE). You may redistribute and modify this software under the
|
|
12
|
+
# terms of the CC BY-SA 3.0 license.
|
|
13
|
+
# ==============================================================================
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
Shadow Sentinel Core Engine
|
|
17
|
+
Single Source of Truth - Linux FHS Compliant Open-Source Daemon
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import json
|
|
24
|
+
import shutil
|
|
25
|
+
import hashlib
|
|
26
|
+
import difflib
|
|
27
|
+
import ctypes
|
|
28
|
+
import struct
|
|
29
|
+
import select
|
|
30
|
+
import signal
|
|
31
|
+
import re
|
|
32
|
+
import subprocess
|
|
33
|
+
|
|
34
|
+
__version__ = "1.0.1"
|
|
35
|
+
__author__ = "automatician (Torsten Frieser)"
|
|
36
|
+
__copyright__ = "Copyright (c) 2026 automatician (Torsten Frieser), Germany / Saxony / Zittau"
|
|
37
|
+
__license__ = "CC-BY-SA-3.0"
|
|
38
|
+
|
|
39
|
+
def get_storage_dirs():
|
|
40
|
+
"""
|
|
41
|
+
Resolves storage directory standard-compliantly:
|
|
42
|
+
1. Environment variable SENTINEL_STORAGE_DIR
|
|
43
|
+
2. Optional config: /etc/shadow-sentinel/config.json or ~/.config/shadow-sentinel/config.json
|
|
44
|
+
3. Linux FHS default: /var/lib/shadow-sentinel (root) or ~/.local/share/shadow-sentinel (user)
|
|
45
|
+
"""
|
|
46
|
+
env_dir = os.getenv("SENTINEL_STORAGE_DIR")
|
|
47
|
+
if env_dir:
|
|
48
|
+
base = os.path.abspath(env_dir)
|
|
49
|
+
else:
|
|
50
|
+
cfg_file = "/etc/shadow-sentinel/config.json" if os.geteuid() == 0 else os.path.expanduser("~/.config/shadow-sentinel/config.json")
|
|
51
|
+
custom_base = None
|
|
52
|
+
if os.path.exists(cfg_file):
|
|
53
|
+
try:
|
|
54
|
+
with open(cfg_file, "r", encoding="utf-8") as f:
|
|
55
|
+
data = json.load(f)
|
|
56
|
+
if "storage_dir" in data:
|
|
57
|
+
custom_base = data["storage_dir"]
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
if custom_base:
|
|
62
|
+
base = os.path.abspath(custom_base)
|
|
63
|
+
elif os.geteuid() == 0:
|
|
64
|
+
base = "/var/lib/shadow-sentinel"
|
|
65
|
+
else:
|
|
66
|
+
base = os.path.expanduser("~/.local/share/shadow-sentinel")
|
|
67
|
+
|
|
68
|
+
backup_dir = os.path.join(base, "backups")
|
|
69
|
+
vault_dir = os.path.join(base, ".shadow_vault")
|
|
70
|
+
index_file = os.path.join(backup_dir, "sentinel_index.json")
|
|
71
|
+
return base, backup_dir, vault_dir, index_file
|
|
72
|
+
|
|
73
|
+
CENTRAL_BASE, BACKUP_DIR, VAULT_DIR, INDEX_FILE = get_storage_dirs()
|
|
74
|
+
|
|
75
|
+
MAX_VERSIONS_PER_FILE = 10
|
|
76
|
+
MAX_BACKUP_AGE_DAYS = 14
|
|
77
|
+
MAX_LOG_AGE_DAYS = 30
|
|
78
|
+
MAX_BACKUP_DIR_MB = 500
|
|
79
|
+
PRUNE_INTERVAL_SEC = 21600
|
|
80
|
+
COOLDOWN_SEC = 3
|
|
81
|
+
|
|
82
|
+
DEFAULT_MAX_FILE_SIZE_MB = 5
|
|
83
|
+
|
|
84
|
+
os.makedirs(BACKUP_DIR, exist_ok=True)
|
|
85
|
+
os.makedirs(VAULT_DIR, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
|
88
|
+
|
|
89
|
+
IN_CLOSE_WRITE = 0x00000008
|
|
90
|
+
IN_MOVED_FROM = 0x00000040
|
|
91
|
+
IN_MOVED_TO = 0x00000080
|
|
92
|
+
IN_CREATE = 0x00000100
|
|
93
|
+
IN_DELETE = 0x00000200
|
|
94
|
+
IN_ISDIR = 0x40000000
|
|
95
|
+
|
|
96
|
+
EVENT_MASK = IN_CLOSE_WRITE | IN_DELETE | IN_MOVED_FROM | IN_MOVED_TO | IN_CREATE
|
|
97
|
+
|
|
98
|
+
running = True
|
|
99
|
+
last_prune_ts = 0
|
|
100
|
+
pending_writes = {}
|
|
101
|
+
marker_targets = {}
|
|
102
|
+
|
|
103
|
+
def signal_handler(sig, frame):
|
|
104
|
+
global running
|
|
105
|
+
print(f"\n[SENTINEL] Signal {sig} received. Terminating guard service...")
|
|
106
|
+
running = False
|
|
107
|
+
|
|
108
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
109
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
110
|
+
|
|
111
|
+
def is_sentinel_internal(path: str) -> bool:
|
|
112
|
+
"""Prevents recursion loops: vault and backup directories do not monitor themselves."""
|
|
113
|
+
abs_p = os.path.abspath(path)
|
|
114
|
+
if abs_p == BACKUP_DIR or abs_p.startswith(BACKUP_DIR + os.sep):
|
|
115
|
+
return True
|
|
116
|
+
if abs_p == VAULT_DIR or abs_p.startswith(VAULT_DIR + os.sep):
|
|
117
|
+
return True
|
|
118
|
+
if abs_p == "/etc/shadow-sentinel" or abs_p.startswith("/etc/shadow-sentinel" + os.sep):
|
|
119
|
+
return True
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def load_index() -> list:
|
|
123
|
+
"""Loads index and automatically discovers unindexed .bak files on the drive."""
|
|
124
|
+
entries = []
|
|
125
|
+
if os.path.exists(INDEX_FILE):
|
|
126
|
+
try:
|
|
127
|
+
with open(INDEX_FILE, "r", encoding="utf-8") as f:
|
|
128
|
+
entries = json.load(f)
|
|
129
|
+
except Exception:
|
|
130
|
+
entries = []
|
|
131
|
+
|
|
132
|
+
# Autonomer Reconcile-Check: Prรผft ob .bak Dateien auf der Platte fehlen
|
|
133
|
+
existing_baks = {item.get("bak_file") for item in entries}
|
|
134
|
+
pattern = re.compile(r"^(.*?)\.(\d{8}_\d{6})\.(bak|deleted\.bak)$")
|
|
135
|
+
log_pattern = re.compile(r"\[SENTINEL_AUTO_(?:BACKUP|DELETED)\]\s+(?:DATEI|FILE):\s+(\S+).*?BACKUP:\s+(\S+)")
|
|
136
|
+
|
|
137
|
+
file_map = {}
|
|
138
|
+
if os.path.exists(BACKUP_DIR):
|
|
139
|
+
for fname in os.listdir(BACKUP_DIR):
|
|
140
|
+
if fname.startswith("diff-") and fname.endswith(".log"):
|
|
141
|
+
try:
|
|
142
|
+
with open(os.path.join(BACKUP_DIR, fname), "r", encoding="utf-8", errors="replace") as lf:
|
|
143
|
+
for line in lf:
|
|
144
|
+
m = log_pattern.search(line)
|
|
145
|
+
if m:
|
|
146
|
+
file_map[m.group(2)] = m.group(1)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
modified = False
|
|
151
|
+
if os.path.exists(BACKUP_DIR):
|
|
152
|
+
for fname in sorted(os.listdir(BACKUP_DIR)):
|
|
153
|
+
if fname in existing_baks:
|
|
154
|
+
continue
|
|
155
|
+
match = pattern.match(fname)
|
|
156
|
+
if match:
|
|
157
|
+
fpath = os.path.join(BACKUP_DIR, fname)
|
|
158
|
+
orig_name = match.group(1)
|
|
159
|
+
ts_str = match.group(2)
|
|
160
|
+
orig_path = file_map.get(fname, os.path.join(CENTRAL_BASE, orig_name))
|
|
161
|
+
diff_file = fname.replace(".bak", ".diff")
|
|
162
|
+
if not os.path.exists(os.path.join(BACKUP_DIR, diff_file)):
|
|
163
|
+
diff_file = ""
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
formatted_ts = time.strftime("%Y-%m-%d %H:%M:%S", time.strptime(ts_str, "%Y%m%d_%H%M%S"))
|
|
167
|
+
except Exception:
|
|
168
|
+
formatted_ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(fpath)))
|
|
169
|
+
|
|
170
|
+
entries.append({
|
|
171
|
+
"id": len(entries) + 1,
|
|
172
|
+
"orig_path": os.path.abspath(orig_path),
|
|
173
|
+
"bak_file": fname,
|
|
174
|
+
"diff_file": diff_file,
|
|
175
|
+
"timestamp": formatted_ts,
|
|
176
|
+
"size_bytes": os.path.getsize(fpath)
|
|
177
|
+
})
|
|
178
|
+
existing_baks.add(fname)
|
|
179
|
+
modified = True
|
|
180
|
+
|
|
181
|
+
if modified or not os.path.exists(INDEX_FILE):
|
|
182
|
+
entries.sort(key=lambda x: x.get("timestamp", ""))
|
|
183
|
+
for idx, item in enumerate(entries, 1):
|
|
184
|
+
item["id"] = idx
|
|
185
|
+
save_index(entries)
|
|
186
|
+
|
|
187
|
+
return entries
|
|
188
|
+
|
|
189
|
+
def save_index(index_list: list):
|
|
190
|
+
try:
|
|
191
|
+
with open(INDEX_FILE, "w", encoding="utf-8") as f:
|
|
192
|
+
json.dump(index_list, f, indent=2, ensure_ascii=False)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
print(f"[SENTINEL-INDEX-ERR] {e}")
|
|
195
|
+
|
|
196
|
+
def record_backup(orig_path: str, bak_name: str, diff_name: str, size_bytes: int):
|
|
197
|
+
idx = load_index()
|
|
198
|
+
next_id = max([item.get("id", 0) for item in idx], default=0) + 1
|
|
199
|
+
|
|
200
|
+
entry = {
|
|
201
|
+
"id": next_id,
|
|
202
|
+
"orig_path": os.path.abspath(orig_path),
|
|
203
|
+
"bak_file": bak_name,
|
|
204
|
+
"diff_file": diff_name,
|
|
205
|
+
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
206
|
+
"size_bytes": size_bytes
|
|
207
|
+
}
|
|
208
|
+
idx.append(entry)
|
|
209
|
+
save_index(idx)
|
|
210
|
+
|
|
211
|
+
def discover_markers() -> dict:
|
|
212
|
+
targets = {}
|
|
213
|
+
cmd = ["find", "/", "-name", "sentinel_marker", "-o", "-name", ".sentinel_marker"]
|
|
214
|
+
try:
|
|
215
|
+
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
|
|
216
|
+
lines = [line.strip() for line in res.stdout.splitlines() if line.strip()]
|
|
217
|
+
except Exception as e:
|
|
218
|
+
print(f"[SENTINEL] Find error: {e}")
|
|
219
|
+
lines = []
|
|
220
|
+
|
|
221
|
+
for mpath in lines:
|
|
222
|
+
if not os.path.isfile(mpath):
|
|
223
|
+
continue
|
|
224
|
+
watch_root = os.path.abspath(os.path.dirname(mpath))
|
|
225
|
+
if is_sentinel_internal(watch_root):
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
limit_mb = DEFAULT_MAX_FILE_SIZE_MB
|
|
229
|
+
try:
|
|
230
|
+
content = ""
|
|
231
|
+
with open(mpath, "r", encoding="utf-8") as f:
|
|
232
|
+
content = f.read().strip()
|
|
233
|
+
|
|
234
|
+
if not content:
|
|
235
|
+
with open(mpath, "w", encoding="utf-8") as f:
|
|
236
|
+
f.write(f"{DEFAULT_MAX_FILE_SIZE_MB}\n")
|
|
237
|
+
limit_mb = DEFAULT_MAX_FILE_SIZE_MB
|
|
238
|
+
else:
|
|
239
|
+
parsed = int(float(content))
|
|
240
|
+
if parsed > 0:
|
|
241
|
+
limit_mb = parsed
|
|
242
|
+
except Exception:
|
|
243
|
+
limit_mb = DEFAULT_MAX_FILE_SIZE_MB
|
|
244
|
+
|
|
245
|
+
targets[watch_root] = limit_mb * 1024 * 1024
|
|
246
|
+
|
|
247
|
+
return targets
|
|
248
|
+
|
|
249
|
+
def find_nearest_marker(start_dir: str):
|
|
250
|
+
cur = os.path.abspath(start_dir)
|
|
251
|
+
while True:
|
|
252
|
+
m1 = os.path.join(cur, "sentinel_marker")
|
|
253
|
+
m2 = os.path.join(cur, ".sentinel_marker")
|
|
254
|
+
if os.path.isfile(m1):
|
|
255
|
+
return cur, m1
|
|
256
|
+
if os.path.isfile(m2):
|
|
257
|
+
return cur, m2
|
|
258
|
+
parent = os.path.dirname(cur)
|
|
259
|
+
if parent == cur:
|
|
260
|
+
break
|
|
261
|
+
cur = parent
|
|
262
|
+
return None, None
|
|
263
|
+
|
|
264
|
+
def get_sha256(filepath: str) -> str:
|
|
265
|
+
try:
|
|
266
|
+
h = hashlib.sha256()
|
|
267
|
+
with open(filepath, "rb") as f:
|
|
268
|
+
while chunk := f.read(65536):
|
|
269
|
+
h.update(chunk)
|
|
270
|
+
return h.hexdigest()
|
|
271
|
+
except Exception:
|
|
272
|
+
return ""
|
|
273
|
+
|
|
274
|
+
def is_monitored_file(path: str) -> bool:
|
|
275
|
+
base = os.path.basename(path)
|
|
276
|
+
if base in ("sentinel_marker", ".sentinel_marker"):
|
|
277
|
+
return False
|
|
278
|
+
if is_sentinel_internal(path):
|
|
279
|
+
return False
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
def get_vault_path(abs_path: str) -> str:
|
|
283
|
+
clean_path = os.path.abspath(abs_path).lstrip("/")
|
|
284
|
+
return os.path.join(VAULT_DIR, clean_path)
|
|
285
|
+
|
|
286
|
+
def get_max_size_for_file(abs_path: str) -> int:
|
|
287
|
+
matched_root = None
|
|
288
|
+
matched_size = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024
|
|
289
|
+
abs_p = os.path.abspath(abs_path)
|
|
290
|
+
for root, size in marker_targets.items():
|
|
291
|
+
if abs_p == root or abs_p.startswith(root + "/"):
|
|
292
|
+
if matched_root is None or len(root) > len(matched_root):
|
|
293
|
+
matched_root = root
|
|
294
|
+
matched_size = size
|
|
295
|
+
return matched_size
|
|
296
|
+
|
|
297
|
+
def auto_prune_backups():
|
|
298
|
+
global last_prune_ts
|
|
299
|
+
now = time.time()
|
|
300
|
+
last_prune_ts = now
|
|
301
|
+
print("[SENTINEL-PRUNER] Starting automated backup pruning...")
|
|
302
|
+
|
|
303
|
+
deleted_count = 0
|
|
304
|
+
freed_bytes = 0
|
|
305
|
+
|
|
306
|
+
pattern = re.compile(r"^(.*?)\.(\d{8}_\d{6})\.(bak|diff|deleted\.bak)$")
|
|
307
|
+
file_groups = {}
|
|
308
|
+
|
|
309
|
+
for fname in os.listdir(BACKUP_DIR):
|
|
310
|
+
fpath = os.path.join(BACKUP_DIR, fname)
|
|
311
|
+
if not os.path.isfile(fpath):
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
if fname.startswith("diff-") and fname.endswith(".log"):
|
|
315
|
+
try:
|
|
316
|
+
st = os.stat(fpath)
|
|
317
|
+
if (now - st.st_mtime) > (MAX_LOG_AGE_DAYS * 86400):
|
|
318
|
+
freed_bytes += st.st_size
|
|
319
|
+
os.remove(fpath)
|
|
320
|
+
deleted_count += 1
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
match = pattern.match(fname)
|
|
326
|
+
if match:
|
|
327
|
+
orig_name = match.group(1)
|
|
328
|
+
ext_type = match.group(3)
|
|
329
|
+
key = (orig_name, ext_type)
|
|
330
|
+
if key not in file_groups:
|
|
331
|
+
file_groups[key] = []
|
|
332
|
+
file_groups[key].append((fname, fpath, os.path.getmtime(fpath), os.path.getsize(fpath)))
|
|
333
|
+
|
|
334
|
+
for (orig_name, ext_type), items in file_groups.items():
|
|
335
|
+
items.sort(key=lambda x: x[2], reverse=True)
|
|
336
|
+
|
|
337
|
+
for idx, (fname, fpath, mtime, size) in enumerate(items):
|
|
338
|
+
should_delete = False
|
|
339
|
+
if idx >= MAX_VERSIONS_PER_FILE:
|
|
340
|
+
should_delete = True
|
|
341
|
+
elif idx > 0 and (now - mtime) > (MAX_BACKUP_AGE_DAYS * 86400):
|
|
342
|
+
should_delete = True
|
|
343
|
+
|
|
344
|
+
if should_delete:
|
|
345
|
+
try:
|
|
346
|
+
os.remove(fpath)
|
|
347
|
+
deleted_count += 1
|
|
348
|
+
freed_bytes += size
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
total_size = sum(os.path.getsize(os.path.join(BACKUP_DIR, f)) for f in os.listdir(BACKUP_DIR) if os.path.isfile(os.path.join(BACKUP_DIR, f)))
|
|
353
|
+
max_bytes = MAX_BACKUP_DIR_MB * 1024 * 1024
|
|
354
|
+
|
|
355
|
+
if total_size > max_bytes:
|
|
356
|
+
all_backups = []
|
|
357
|
+
for fname in os.listdir(BACKUP_DIR):
|
|
358
|
+
fpath = os.path.join(BACKUP_DIR, fname)
|
|
359
|
+
if os.path.isfile(fpath):
|
|
360
|
+
all_backups.append((fpath, os.path.getmtime(fpath), os.path.getsize(fpath)))
|
|
361
|
+
all_backups.sort(key=lambda x: x[1])
|
|
362
|
+
for fpath, _, size in all_backups:
|
|
363
|
+
if total_size <= max_bytes * 0.8:
|
|
364
|
+
break
|
|
365
|
+
try:
|
|
366
|
+
os.remove(fpath)
|
|
367
|
+
total_size -= size
|
|
368
|
+
freed_bytes += size
|
|
369
|
+
deleted_count += 1
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
freed_mb = round(freed_bytes / (1024 * 1024), 2)
|
|
374
|
+
print(f"[SENTINEL-PRUNER] Pruning complete: {deleted_count} stale versions deleted ({freed_mb} MB freed).")
|
|
375
|
+
|
|
376
|
+
def sync_initial_vault():
|
|
377
|
+
print(f"[SENTINEL] Synchronizing shadow vault (storage base: {CENTRAL_BASE})...")
|
|
378
|
+
count = 0
|
|
379
|
+
ejected_count = 0
|
|
380
|
+
|
|
381
|
+
for root_dir, max_bytes in marker_targets.items():
|
|
382
|
+
if not os.path.exists(root_dir) or is_sentinel_internal(root_dir):
|
|
383
|
+
continue
|
|
384
|
+
for root, dirs, files in os.walk(root_dir):
|
|
385
|
+
dirs[:] = [d for d in dirs if not is_sentinel_internal(os.path.join(root, d))]
|
|
386
|
+
for f in files:
|
|
387
|
+
full_path = os.path.join(root, f)
|
|
388
|
+
if not is_monitored_file(full_path):
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
cur_size = os.path.getsize(full_path)
|
|
393
|
+
except Exception:
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
vault_path = get_vault_path(full_path)
|
|
397
|
+
|
|
398
|
+
if cur_size > max_bytes:
|
|
399
|
+
if os.path.exists(vault_path):
|
|
400
|
+
try:
|
|
401
|
+
os.remove(vault_path)
|
|
402
|
+
ejected_count += 1
|
|
403
|
+
except Exception:
|
|
404
|
+
pass
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
os.makedirs(os.path.dirname(vault_path), exist_ok=True)
|
|
408
|
+
if not os.path.exists(vault_path) or get_sha256(full_path) != get_sha256(vault_path):
|
|
409
|
+
shutil.copy2(full_path, vault_path)
|
|
410
|
+
count += 1
|
|
411
|
+
|
|
412
|
+
print(f"[SENTINEL] Shadow vault ready ({count} files tracked, {ejected_count} exceeded limit and ejected).")
|
|
413
|
+
auto_prune_backups()
|
|
414
|
+
|
|
415
|
+
def handle_file_written(abs_path: str):
|
|
416
|
+
if not os.path.exists(abs_path) or not is_monitored_file(abs_path):
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
vault_path = get_vault_path(abs_path)
|
|
420
|
+
max_bytes = get_max_size_for_file(abs_path)
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
cur_size = os.path.getsize(abs_path)
|
|
424
|
+
except Exception:
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
if cur_size > max_bytes:
|
|
428
|
+
if os.path.exists(vault_path):
|
|
429
|
+
try:
|
|
430
|
+
os.remove(vault_path)
|
|
431
|
+
size_mb = round(cur_size / (1024 * 1024), 2)
|
|
432
|
+
limit_mb = round(max_bytes / (1024 * 1024), 1)
|
|
433
|
+
print(f"[SENTINEL] SIZE CEILING EXCEEDED ({size_mb} MB > {limit_mb} MB): {abs_path} ejected from shadow vault.")
|
|
434
|
+
except Exception:
|
|
435
|
+
pass
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
os.makedirs(os.path.dirname(vault_path), exist_ok=True)
|
|
439
|
+
new_hash = get_sha256(abs_path)
|
|
440
|
+
if not new_hash:
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
ts = time.strftime("%Y%m%d_%H%M%S")
|
|
444
|
+
base_name = os.path.basename(abs_path)
|
|
445
|
+
|
|
446
|
+
if os.path.exists(vault_path):
|
|
447
|
+
old_hash = get_sha256(vault_path)
|
|
448
|
+
if old_hash != new_hash:
|
|
449
|
+
bak_name = f"{base_name}.{ts}.bak"
|
|
450
|
+
bak_path = os.path.join(BACKUP_DIR, bak_name)
|
|
451
|
+
shutil.copy2(vault_path, bak_path)
|
|
452
|
+
|
|
453
|
+
diff_name = f"{base_name}.{ts}.diff"
|
|
454
|
+
diff_path = os.path.join(BACKUP_DIR, diff_name)
|
|
455
|
+
try:
|
|
456
|
+
with open(vault_path, "r", encoding="utf-8", errors="replace") as fo, \
|
|
457
|
+
open(abs_path, "r", encoding="utf-8", errors="replace") as fn:
|
|
458
|
+
old_lines = fo.readlines()
|
|
459
|
+
new_lines = fn.readlines()
|
|
460
|
+
diff = list(difflib.unified_diff(
|
|
461
|
+
old_lines, new_lines,
|
|
462
|
+
fromfile=f"CURRENT: {abs_path}",
|
|
463
|
+
tofile=f"TARGET: {abs_path}",
|
|
464
|
+
lineterm="\n"
|
|
465
|
+
))
|
|
466
|
+
if diff:
|
|
467
|
+
with open(diff_path, "w", encoding="utf-8") as df:
|
|
468
|
+
df.write(f"=== SENTINEL AUTO-DIFF: {abs_path} ===\n")
|
|
469
|
+
df.write(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')} | Hash: {new_hash[:12]}\n\n")
|
|
470
|
+
df.writelines(diff)
|
|
471
|
+
except Exception:
|
|
472
|
+
pass
|
|
473
|
+
|
|
474
|
+
today_str = time.strftime("%Y%m%d")
|
|
475
|
+
with open(os.path.join(BACKUP_DIR, f"diff-{today_str}.log"), "a", encoding="utf-8") as lf:
|
|
476
|
+
lf.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [SENTINEL_AUTO_BACKUP] FILE: {abs_path} | SHA256: {new_hash[:12]} | BACKUP: {bak_name} | DIFF: {diff_name}\n")
|
|
477
|
+
|
|
478
|
+
record_backup(abs_path, bak_name, diff_name, cur_size)
|
|
479
|
+
print(f"[SENTINEL] MODIFICATION BACKED UP: {abs_path} -> {bak_name}")
|
|
480
|
+
shutil.copy2(abs_path, vault_path)
|
|
481
|
+
else:
|
|
482
|
+
shutil.copy2(abs_path, vault_path)
|
|
483
|
+
print(f"[SENTINEL] NEW FILE REGISTERED IN VAULT: {abs_path}")
|
|
484
|
+
|
|
485
|
+
def handle_file_deleted(abs_path: str):
|
|
486
|
+
if not is_monitored_file(abs_path):
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
vault_path = get_vault_path(abs_path)
|
|
490
|
+
base_name = os.path.basename(abs_path)
|
|
491
|
+
|
|
492
|
+
if os.path.exists(vault_path):
|
|
493
|
+
ts = time.strftime("%Y%m%d_%H%M%S")
|
|
494
|
+
del_bak = f"{base_name}.{ts}.deleted.bak"
|
|
495
|
+
bak_path = os.path.join(BACKUP_DIR, del_bak)
|
|
496
|
+
shutil.copy2(vault_path, bak_path)
|
|
497
|
+
|
|
498
|
+
today_str = time.strftime("%Y%m%d")
|
|
499
|
+
with open(os.path.join(BACKUP_DIR, f"diff-{today_str}.log"), "a", encoding="utf-8") as lf:
|
|
500
|
+
lf.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [SENTINEL_AUTO_DELETED] FILE: {abs_path} | BACKUP: {del_bak}\n")
|
|
501
|
+
|
|
502
|
+
record_backup(abs_path, del_bak, "", 0)
|
|
503
|
+
os.remove(vault_path)
|
|
504
|
+
print(f"[SENTINEL] DELETION BACKED UP: {abs_path} -> {del_bak}")
|
|
505
|
+
|
|
506
|
+
def run_daemon():
|
|
507
|
+
global last_prune_ts, pending_writes, marker_targets
|
|
508
|
+
marker_targets = discover_markers()
|
|
509
|
+
|
|
510
|
+
print(f"[SENTINEL] Guard active. Storage base: {CENTRAL_BASE}")
|
|
511
|
+
if not marker_targets:
|
|
512
|
+
print("[SENTINEL] Info: No 'sentinel_marker' found. Create a marker with 'sentinel init' in any directory to begin monitoring.")
|
|
513
|
+
else:
|
|
514
|
+
print("[SENTINEL] Active marker roots:")
|
|
515
|
+
for root, max_bytes in marker_targets.items():
|
|
516
|
+
print(f" * {root} (Limit: {max_bytes // (1024*1024)} MB)")
|
|
517
|
+
|
|
518
|
+
sync_initial_vault()
|
|
519
|
+
|
|
520
|
+
inotify_fd = libc.inotify_init1(0)
|
|
521
|
+
if inotify_fd < 0:
|
|
522
|
+
print("[SENTINEL] Error: inotify_init1 failed!")
|
|
523
|
+
sys.exit(1)
|
|
524
|
+
|
|
525
|
+
wd_to_dir = {}
|
|
526
|
+
|
|
527
|
+
def add_watch_recursive(path: str):
|
|
528
|
+
if not os.path.exists(path) or is_sentinel_internal(path):
|
|
529
|
+
return
|
|
530
|
+
for root, dirs, _ in os.walk(path):
|
|
531
|
+
dirs[:] = [d for d in dirs if not is_sentinel_internal(os.path.join(root, d))]
|
|
532
|
+
wd = libc.inotify_add_watch(inotify_fd, root.encode("utf-8"), EVENT_MASK)
|
|
533
|
+
if wd >= 0:
|
|
534
|
+
wd_to_dir[wd] = root
|
|
535
|
+
|
|
536
|
+
for p in marker_targets.keys():
|
|
537
|
+
add_watch_recursive(p)
|
|
538
|
+
|
|
539
|
+
print(f"[SENTINEL] Inotify engine watching {len(wd_to_dir)} directories.")
|
|
540
|
+
|
|
541
|
+
poll = select.poll()
|
|
542
|
+
poll.register(inotify_fd, select.POLLIN)
|
|
543
|
+
|
|
544
|
+
while running:
|
|
545
|
+
try:
|
|
546
|
+
now = time.time()
|
|
547
|
+
if now - last_prune_ts > PRUNE_INTERVAL_SEC:
|
|
548
|
+
auto_prune_backups()
|
|
549
|
+
|
|
550
|
+
if pending_writes:
|
|
551
|
+
ready_to_write = []
|
|
552
|
+
for fpath, (first_ts, last_ts) in list(pending_writes.items()):
|
|
553
|
+
if (now - last_ts >= COOLDOWN_SEC) or (now - first_ts >= 30.0):
|
|
554
|
+
ready_to_write.append(fpath)
|
|
555
|
+
|
|
556
|
+
for fpath in ready_to_write:
|
|
557
|
+
del pending_writes[fpath]
|
|
558
|
+
handle_file_written(fpath)
|
|
559
|
+
|
|
560
|
+
events = poll.poll(500)
|
|
561
|
+
if not events:
|
|
562
|
+
continue
|
|
563
|
+
|
|
564
|
+
raw = os.read(inotify_fd, 65536)
|
|
565
|
+
offset = 0
|
|
566
|
+
while offset < len(raw):
|
|
567
|
+
wd, mask, cookie, length = struct.unpack_from("iIII", raw, offset)
|
|
568
|
+
offset += 16
|
|
569
|
+
name = raw[offset:offset + length].rstrip(b"\x00").decode("utf-8", errors="replace")
|
|
570
|
+
offset += length
|
|
571
|
+
|
|
572
|
+
dir_path = wd_to_dir.get(wd)
|
|
573
|
+
if not dir_path or not name:
|
|
574
|
+
continue
|
|
575
|
+
|
|
576
|
+
full_path = os.path.join(dir_path, name)
|
|
577
|
+
|
|
578
|
+
if mask & IN_ISDIR:
|
|
579
|
+
if (mask & (IN_CREATE | IN_MOVED_TO)) and not is_sentinel_internal(full_path):
|
|
580
|
+
new_wd = libc.inotify_add_watch(inotify_fd, full_path.encode("utf-8"), EVENT_MASK)
|
|
581
|
+
if new_wd >= 0:
|
|
582
|
+
wd_to_dir[new_wd] = full_path
|
|
583
|
+
continue
|
|
584
|
+
|
|
585
|
+
if mask & (IN_CLOSE_WRITE | IN_MOVED_TO):
|
|
586
|
+
if COOLDOWN_SEC <= 0:
|
|
587
|
+
handle_file_written(full_path)
|
|
588
|
+
else:
|
|
589
|
+
event_time = time.time()
|
|
590
|
+
if full_path in pending_writes:
|
|
591
|
+
pending_writes[full_path] = (pending_writes[full_path][0], event_time)
|
|
592
|
+
else:
|
|
593
|
+
pending_writes[full_path] = (event_time, event_time)
|
|
594
|
+
|
|
595
|
+
elif mask & (IN_DELETE | IN_MOVED_FROM):
|
|
596
|
+
if full_path in pending_writes:
|
|
597
|
+
del pending_writes[full_path]
|
|
598
|
+
handle_file_deleted(full_path)
|
|
599
|
+
|
|
600
|
+
except Exception as e:
|
|
601
|
+
if running:
|
|
602
|
+
print(f"[SENTINEL-ERROR]: {e}")
|
|
603
|
+
time.sleep(1)
|
|
604
|
+
|
|
605
|
+
if pending_writes:
|
|
606
|
+
for fpath in list(pending_writes.keys()):
|
|
607
|
+
handle_file_written(fpath)
|
|
608
|
+
pending_writes.clear()
|
|
609
|
+
|
|
610
|
+
os.close(inotify_fd)
|
|
611
|
+
print("[SENTINEL] Guard terminated.")
|
|
612
|
+
|
|
613
|
+
def manage_service(action: str):
|
|
614
|
+
service_file = "/etc/systemd/system/shadow-sentinel.service"
|
|
615
|
+
py_exec = sys.executable
|
|
616
|
+
|
|
617
|
+
if action == "install":
|
|
618
|
+
content = f"""[Unit]
|
|
619
|
+
Description=Shadow Sentinel - Autonomous Filesystem Revision & Backup Guard
|
|
620
|
+
After=network.target
|
|
621
|
+
|
|
622
|
+
[Service]
|
|
623
|
+
Type=simple
|
|
624
|
+
ExecStart={py_exec} -m shadow_sentinel.core daemon
|
|
625
|
+
Restart=always
|
|
626
|
+
RestartSec=5
|
|
627
|
+
StandardOutput=journal
|
|
628
|
+
StandardError=journal
|
|
629
|
+
|
|
630
|
+
[Install]
|
|
631
|
+
WantedBy=multi-user.target
|
|
632
|
+
"""
|
|
633
|
+
try:
|
|
634
|
+
with open(service_file, "w", encoding="utf-8") as f:
|
|
635
|
+
f.write(content)
|
|
636
|
+
subprocess.run(["systemctl", "daemon-reload"], check=True)
|
|
637
|
+
subprocess.run(["systemctl", "enable", "--now", "shadow-sentinel"], check=True)
|
|
638
|
+
print(f"[SENTINEL] โ
Service successfully installed and started: {service_file}")
|
|
639
|
+
print("Check status with: sentinel service status")
|
|
640
|
+
except Exception as e:
|
|
641
|
+
print(f"[SENTINEL-ERROR] Service installation failed (root privileges required?): {e}")
|
|
642
|
+
|
|
643
|
+
elif action in ("status", "restart", "stop", "start"):
|
|
644
|
+
subprocess.run(["systemctl", action, "shadow-sentinel"])
|
|
645
|
+
else:
|
|
646
|
+
print("Unknown service action. Available: install, status, restart, start, stop")
|
|
647
|
+
|
|
648
|
+
def print_version():
|
|
649
|
+
print(f"Shadow Sentinel v{__version__}")
|
|
650
|
+
print(f"{__copyright__}")
|
|
651
|
+
print(f"License: {__license__} (Creative Commons Attribution-ShareAlike 3.0 Germany)")
|
|
652
|
+
print("Project: https://github.com/automatician/shadow-sentinel")
|
|
653
|
+
|
|
654
|
+
def show_help():
|
|
655
|
+
manual = f"""
|
|
656
|
+
====================================================================================================
|
|
657
|
+
SHADOW SENTINEL - AUTONOMOUS OS-LEVEL REVISION & BACKUP DAEMON
|
|
658
|
+
====================================================================================================
|
|
659
|
+
|
|
660
|
+
VERSION & LICENSE
|
|
661
|
+
Version: v{__version__}
|
|
662
|
+
Author: {__author__}
|
|
663
|
+
Copyright: {__copyright__}
|
|
664
|
+
License: {__license__} (Creative Commons Attribution-ShareAlike 3.0 DE)
|
|
665
|
+
Repository: https://github.com/automatician/shadow-sentinel
|
|
666
|
+
|
|
667
|
+
DESCRIPTION
|
|
668
|
+
Shadow Sentinel is a lightweight, zero-dependency, kernel-level filesystem revision guard.
|
|
669
|
+
It monitors arbitrary directory trees via Linux inotify (libc), captures atomic backups (.bak)
|
|
670
|
+
and human-readable unified diffs (.diff) on every write, and provides instant 1-command restores
|
|
671
|
+
with loop-coupling safety.
|
|
672
|
+
|
|
673
|
+
CORE ARCHITECTURAL CONCEPTS
|
|
674
|
+
|
|
675
|
+
1. Decentralized Marker Control (sentinel_marker)
|
|
676
|
+
- No cumbersome central path registration required.
|
|
677
|
+
- Placing a file named 'sentinel_marker' into any directory instructs the daemon to monitor
|
|
678
|
+
that directory and all its subdirectories recursively.
|
|
679
|
+
- On daemon startup or server reboot, the daemon scans the system and binds all marked roots.
|
|
680
|
+
|
|
681
|
+
2. Dynamic File-Size Ceiling (Self-Pruning Vault)
|
|
682
|
+
- The integer stored inside 'sentinel_marker' defines the maximum protected file size in MB.
|
|
683
|
+
Default is 5 (for 5 MB).
|
|
684
|
+
- If a file (such as a runaway logfile) exceeds this ceiling, it is automatically ejected
|
|
685
|
+
from the shadow vault. Small scripts, configurations, and source code stay 100% protected.
|
|
686
|
+
|
|
687
|
+
3. Loop-Coupling (Undo-Safe Restore)
|
|
688
|
+
- Restoring a file with 'sentinel restore <ID>' overwrites the current file.
|
|
689
|
+
- The running inotify daemon immediately intercepts this write and seals the overwritten
|
|
690
|
+
state into a brand-new backup revision.
|
|
691
|
+
- An accidental restore never destroys data; any mistaken restore can instantly be undone!
|
|
692
|
+
|
|
693
|
+
4. Flapping & Cooldown Debounce
|
|
694
|
+
- High-frequency write streams (e.g. active apps writing multiple times per second) are debounced
|
|
695
|
+
with a 3-second quiet window to prevent backup spam.
|
|
696
|
+
|
|
697
|
+
AUTOMATED DISK SANITATION & RETENTION DOCTRINE
|
|
698
|
+
|
|
699
|
+
Sentinel guarantees that it will never exhaust disk space through a 4-tier automated policy:
|
|
700
|
+
1. Version Cap per File (Max 10 Revisions):
|
|
701
|
+
Only the 10 most recent .bak and .diff files per target are kept. Older versions are deleted.
|
|
702
|
+
2. Version Age Expiration (14 Days):
|
|
703
|
+
Revisions older than 14 days are automatically pruned.
|
|
704
|
+
3. Log Expiration (30 Days):
|
|
705
|
+
Daily audit diff logs (diff-YYYYMMDD.log) are removed after 30 days.
|
|
706
|
+
4. Emergency Storage Ceiling (500 MB Cap):
|
|
707
|
+
If total backup directory size exceeds 500 MB, an emergency purge trims the oldest backups
|
|
708
|
+
across all files until total disk usage drops back down to 80% (400 MB).
|
|
709
|
+
5. Active Vault Ejection:
|
|
710
|
+
Files that grow beyond the MB ceiling in 'sentinel_marker' are immediately removed from
|
|
711
|
+
the .shadow_vault to recover disk space instantly.
|
|
712
|
+
|
|
713
|
+
*The pruning engine runs automatically every 6 hours and immediately upon service startup.*
|
|
714
|
+
|
|
715
|
+
COMMANDS
|
|
716
|
+
|
|
717
|
+
sentinel [filter_term]
|
|
718
|
+
Lists ALL backup revisions for the current context (no artificial cutoffs). Sentinel walks
|
|
719
|
+
upwards from the current directory to locate the nearest 'sentinel_marker'.
|
|
720
|
+
Optional: pass a search term (e.g. 'sentinel .py' or 'sentinel nginx') to filter.
|
|
721
|
+
|
|
722
|
+
sentinel restore <ID>
|
|
723
|
+
Restores the exact file revision identified by <ID>. Overwrites the target file while
|
|
724
|
+
automatically creating a safety backup of the overwritten version (loop-coupling).
|
|
725
|
+
|
|
726
|
+
sentinel diff <ID>
|
|
727
|
+
Outputs the full unified diff of revision <ID> directly to stdout.
|
|
728
|
+
|
|
729
|
+
sentinel init [MAX_SIZE_MB]
|
|
730
|
+
Initializes the current working directory as a monitored root by creating 'sentinel_marker'.
|
|
731
|
+
Optional argument MAX_SIZE_MB sets the size ceiling (default: 5 MB).
|
|
732
|
+
|
|
733
|
+
sentinel markers
|
|
734
|
+
Scans and lists all active 'sentinel_marker' roots across the entire system.
|
|
735
|
+
|
|
736
|
+
sentinel daemon
|
|
737
|
+
Runs the Sentinel monitoring engine in the foreground.
|
|
738
|
+
|
|
739
|
+
sentinel service <action>
|
|
740
|
+
Manages the systemd background daemon.
|
|
741
|
+
Actions:
|
|
742
|
+
install - Generates /etc/systemd/system/shadow-sentinel.service and starts it.
|
|
743
|
+
status - Checks the systemd service status.
|
|
744
|
+
restart - Restarts the background daemon (rescans all markers).
|
|
745
|
+
stop - Stops the daemon.
|
|
746
|
+
start - Starts the daemon.
|
|
747
|
+
|
|
748
|
+
sentinel version, --version, -v
|
|
749
|
+
Displays version, copyright, and license information.
|
|
750
|
+
|
|
751
|
+
sentinel help, --help, -h
|
|
752
|
+
Displays this manual.
|
|
753
|
+
|
|
754
|
+
STORAGE & CONFIGURATION
|
|
755
|
+
|
|
756
|
+
Default Linux FHS Storage Paths:
|
|
757
|
+
- Root daemon: /var/lib/shadow-sentinel/
|
|
758
|
+
- User daemon: ~/.local/share/shadow-sentinel/
|
|
759
|
+
|
|
760
|
+
Custom Storage Redirection (/etc/shadow-sentinel/config.json):
|
|
761
|
+
Sentinel supports an optional configuration file to redirect backup storage:
|
|
762
|
+
Root: /etc/shadow-sentinel/config.json
|
|
763
|
+
User: ~/.config/shadow-sentinel/config.json
|
|
764
|
+
|
|
765
|
+
Example configuration:
|
|
766
|
+
{{
|
|
767
|
+
"storage_dir": "/custom/path/to/backups"
|
|
768
|
+
}}
|
|
769
|
+
|
|
770
|
+
When present, all backups, shadow vaults, and index records are stored under 'storage_dir'.
|
|
771
|
+
|
|
772
|
+
Environment Variable Override:
|
|
773
|
+
Export SENTINEL_STORAGE_DIR=/path/to/storage to override both defaults and configuration files.
|
|
774
|
+
|
|
775
|
+
====================================================================================================
|
|
776
|
+
"""
|
|
777
|
+
print(manual)
|
|
778
|
+
|
|
779
|
+
def cli_main():
|
|
780
|
+
args = sys.argv[1:]
|
|
781
|
+
cmd = args[0] if args else "list"
|
|
782
|
+
|
|
783
|
+
if cmd in ("version", "--version", "-v"):
|
|
784
|
+
print_version()
|
|
785
|
+
return
|
|
786
|
+
|
|
787
|
+
if cmd in ("help", "--help", "-h"):
|
|
788
|
+
show_help()
|
|
789
|
+
return
|
|
790
|
+
|
|
791
|
+
if cmd in ("daemon", "--daemon", "run"):
|
|
792
|
+
run_daemon()
|
|
793
|
+
return
|
|
794
|
+
|
|
795
|
+
if cmd == "service":
|
|
796
|
+
subaction = args[1] if len(args) > 1 else "status"
|
|
797
|
+
manage_service(subaction)
|
|
798
|
+
return
|
|
799
|
+
|
|
800
|
+
if cmd == "init":
|
|
801
|
+
limit = args[1] if len(args) > 1 else str(DEFAULT_MAX_FILE_SIZE_MB)
|
|
802
|
+
mfile = os.path.join(os.getcwd(), "sentinel_marker")
|
|
803
|
+
with open(mfile, "w", encoding="utf-8") as f:
|
|
804
|
+
f.write(f"{limit}\n")
|
|
805
|
+
print(f"[SENTINEL] โ
Marker created: {mfile} (Max file size: {limit} MB)")
|
|
806
|
+
print("Reload daemon with: sentinel service restart")
|
|
807
|
+
return
|
|
808
|
+
|
|
809
|
+
if cmd == "markers":
|
|
810
|
+
print("\n" + "=" * 70)
|
|
811
|
+
print(" ๐ก๏ธ SHADOW SENTINEL - ACTIVE MARKERS ON SYSTEM")
|
|
812
|
+
print("=" * 70)
|
|
813
|
+
targets = discover_markers()
|
|
814
|
+
if not targets:
|
|
815
|
+
print(" (No markers found. Use 'sentinel init' to set one)")
|
|
816
|
+
for root, max_bytes in targets.items():
|
|
817
|
+
print(f" * {root:<50} [Ceiling: {max_bytes // (1024*1024)} MB]")
|
|
818
|
+
print("=" * 70 + "\n")
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
if cmd == "diff":
|
|
822
|
+
if len(args) < 2:
|
|
823
|
+
print("Usage: sentinel diff <ID>")
|
|
824
|
+
sys.exit(1)
|
|
825
|
+
target_id = args[1]
|
|
826
|
+
idx = load_index()
|
|
827
|
+
found = next((item for item in idx if str(item.get("id")) == target_id), None)
|
|
828
|
+
if not found or not found.get("diff_file"):
|
|
829
|
+
print(f"[SENTINEL] No diff available for ID {target_id}.")
|
|
830
|
+
return
|
|
831
|
+
diff_path = os.path.join(BACKUP_DIR, found["diff_file"])
|
|
832
|
+
if os.path.exists(diff_path):
|
|
833
|
+
with open(diff_path, "r", encoding="utf-8", errors="replace") as df:
|
|
834
|
+
print(df.read())
|
|
835
|
+
else:
|
|
836
|
+
print(f"[SENTINEL] Diff file no longer exists: {diff_path}")
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
if cmd == "restore":
|
|
840
|
+
if len(args) < 2:
|
|
841
|
+
print("Usage: sentinel restore <ID>")
|
|
842
|
+
sys.exit(1)
|
|
843
|
+
|
|
844
|
+
target_id = args[1]
|
|
845
|
+
idx = load_index()
|
|
846
|
+
found = next((item for item in idx if str(item.get("id")) == target_id), None)
|
|
847
|
+
|
|
848
|
+
if not found:
|
|
849
|
+
print(f"[SENTINEL-ERROR] No backup revision found with ID {target_id}!")
|
|
850
|
+
sys.exit(1)
|
|
851
|
+
|
|
852
|
+
bak_path = os.path.join(BACKUP_DIR, found["bak_file"])
|
|
853
|
+
orig_path = found["orig_path"]
|
|
854
|
+
|
|
855
|
+
if not os.path.exists(bak_path):
|
|
856
|
+
print(f"[SENTINEL-ERROR] Backup file no longer exists: {bak_path}")
|
|
857
|
+
sys.exit(1)
|
|
858
|
+
|
|
859
|
+
os.makedirs(os.path.dirname(orig_path), exist_ok=True)
|
|
860
|
+
# LOOP-COUPLING: Triggers inotify -> previous version automatically sealed as new backup
|
|
861
|
+
shutil.copy2(bak_path, orig_path)
|
|
862
|
+
print(f"\n[SENTINEL] โ
Revision {target_id} restored to:\n -> {orig_path}")
|
|
863
|
+
print("[SENTINEL] ๐ Loop-coupling active: Overwritten version was automatically backed up!\n")
|
|
864
|
+
return
|
|
865
|
+
|
|
866
|
+
# Standard: Liste ALLE Backups im aktuellen Marker-Zweig (kein 30er Limit mehr!)
|
|
867
|
+
cwd = os.getcwd()
|
|
868
|
+
nearest_root, marker_path = find_nearest_marker(cwd)
|
|
869
|
+
idx = load_index()
|
|
870
|
+
|
|
871
|
+
# Optionaler Suchfilter (z.B. 'sentinel .py' oder 'sentinel mre')
|
|
872
|
+
filter_pattern = args[0] if (args and args[0] != "list") else None
|
|
873
|
+
|
|
874
|
+
print("\n" + "=" * 90)
|
|
875
|
+
print(" ๐ก๏ธ SHADOW SENTINEL - FILE REVISIONS & RESTORATION")
|
|
876
|
+
print("=" * 90)
|
|
877
|
+
|
|
878
|
+
if nearest_root:
|
|
879
|
+
marker_val = "5 MB (Default)"
|
|
880
|
+
try:
|
|
881
|
+
with open(marker_path, "r", encoding="utf-8") as mf:
|
|
882
|
+
val = mf.read().strip()
|
|
883
|
+
if val:
|
|
884
|
+
marker_val = f"{val} MB"
|
|
885
|
+
except Exception:
|
|
886
|
+
pass
|
|
887
|
+
print(f"Current Context: {cwd}")
|
|
888
|
+
print(f"Responsible Sentinel Root: {nearest_root} (Ceiling: {marker_val})")
|
|
889
|
+
filtered = [item for item in idx if item["orig_path"].startswith(nearest_root)]
|
|
890
|
+
else:
|
|
891
|
+
print(f"Current Context: {cwd} (No local marker found - showing all)")
|
|
892
|
+
filtered = idx
|
|
893
|
+
|
|
894
|
+
if filter_pattern:
|
|
895
|
+
print(f"Active Filter: '{filter_pattern}'")
|
|
896
|
+
filtered = [item for item in filtered if filter_pattern.lower() in item["orig_path"].lower()]
|
|
897
|
+
|
|
898
|
+
print("-" * 90)
|
|
899
|
+
if not filtered:
|
|
900
|
+
print(" No backup revisions found for this context.")
|
|
901
|
+
else:
|
|
902
|
+
print(f"{'ID':<6} {'TIMESTAMP':<20} {'FILE SIZE':<14} {'DIFF':<6} ORIGINAL PATH")
|
|
903
|
+
print("-" * 90)
|
|
904
|
+
# Vollstรคndige Liste ausgeben โ KEIN CUTOFF MEHR!
|
|
905
|
+
for item in reversed(filtered):
|
|
906
|
+
diff_label = "[YES]" if item.get("diff_file") else "[--]"
|
|
907
|
+
size_kb = f"{round(item.get('size_bytes', 0) / 1024, 1)} KB"
|
|
908
|
+
display_path = item["orig_path"]
|
|
909
|
+
if nearest_root and display_path.startswith(nearest_root):
|
|
910
|
+
display_path = os.path.relpath(display_path, nearest_root)
|
|
911
|
+
|
|
912
|
+
print(f"[{item['id']}] {item['timestamp']:<20} {size_kb:<14} {diff_label:<6} {display_path}")
|
|
913
|
+
|
|
914
|
+
print("=" * 90)
|
|
915
|
+
print(f" Total Revisions: {len(filtered)} (Scroll up to view older entries)")
|
|
916
|
+
print(" ๐ก RESTORE: sentinel restore <ID>")
|
|
917
|
+
print(" ๐ VIEW DIFF: sentinel diff <ID>")
|
|
918
|
+
print(" โ NEW MARKER: sentinel init [max_mb]")
|
|
919
|
+
print(" ๐ LIST MARKERS: sentinel markers")
|
|
920
|
+
print(" โ๏ธ SYSTEMD SERVICE: sentinel service install|restart|status")
|
|
921
|
+
print(" ๐ท๏ธ VERSION & LICENSE: sentinel version")
|
|
922
|
+
print(" ๐ HELP & MANUAL: sentinel help")
|
|
923
|
+
print("=" * 90 + "\n")
|
|
924
|
+
|
|
925
|
+
if __name__ == "__main__":
|
|
926
|
+
cli_main()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shadow-sentinel
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Autonomous OS-level revision & backup guard with decentralized marker control and loop-coupling
|
|
5
|
+
Author-email: automatician <235166879+automatician@users.noreply.github.com>
|
|
6
|
+
License-Expression: CC-BY-SA-3.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/automatician/shadow-sentinel
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/automatician/shadow-sentinel/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
12
|
+
Classifier: Topic :: System :: Monitoring
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# ๐ก๏ธ Shadow Sentinel
|
|
20
|
+
|
|
21
|
+
**Autonomous OS-Level Revision & Backup Daemon with Decentralized Marker Control and Loop-Coupling.**
|
|
22
|
+
|
|
23
|
+
Shadow Sentinel monitors directory trees in real-time via the Linux kernel (`inotify`), captures
|
|
24
|
+
atomic `.bak` revisions and unified diffs (`.diff`) on every file modification, and provides
|
|
25
|
+
instant 1-command restores with loop-coupling safety.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## ๐ Key Features
|
|
30
|
+
|
|
31
|
+
1. **Decentralized Marker Control (`sentinel_marker`)**:
|
|
32
|
+
- No tedious editing of central configuration files.
|
|
33
|
+
- Simply run `sentinel init [MB]` in any directory or place a `sentinel_marker` file.
|
|
34
|
+
- The daemon automatically scans the system on startup and binds all marked roots.
|
|
35
|
+
|
|
36
|
+
2. **Dynamic File-Size Ceiling (Self-Pruning Vault)**:
|
|
37
|
+
- The value inside `sentinel_marker` sets the protected size ceiling (e.g. `5` for 5 MB).
|
|
38
|
+
- If a file (such as a runaway logfile) exceeds this ceiling, it is automatically ejected
|
|
39
|
+
from the shadow vault. Small scripts, configurations, and source code stay 100% protected.
|
|
40
|
+
|
|
41
|
+
3. **Loop-Coupling (Undo-Safe Restore)**:
|
|
42
|
+
- Restoring a file with `sentinel restore <ID>` overwrites the target file.
|
|
43
|
+
- The running inotify daemon immediately intercepts this write and seals the overwritten
|
|
44
|
+
version into a brand-new backup revision. Mistaken restores can instantly be undone!
|
|
45
|
+
|
|
46
|
+
4. **Flapping & Cooldown Debounce**:
|
|
47
|
+
- Rapid consecutive writes are debounced with a 3-second quiet window to prevent spam.
|
|
48
|
+
|
|
49
|
+
5. **Zero Dependencies**:
|
|
50
|
+
- Pure Python standard library with native `ctypes` bindings directly to `libc.so.6`.
|
|
51
|
+
- No external tools like `inotifywait` or background Git daemons required.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## ๐งน Automated Disk Sanitation & Retention Doctrine
|
|
56
|
+
|
|
57
|
+
Sentinel guarantees that it will never exhaust disk space through a 4-tier automated policy:
|
|
58
|
+
|
|
59
|
+
1. **Version Cap per File (Max 10 Revisions)**:
|
|
60
|
+
Only the 10 most recent `.bak` and `.diff` files per target are kept. Older versions are deleted.
|
|
61
|
+
|
|
62
|
+
2. **Version Age Expiration (14 Days)**:
|
|
63
|
+
Revisions older than 14 days are automatically pruned.
|
|
64
|
+
|
|
65
|
+
3. **Log Expiration (30 Days)**:
|
|
66
|
+
Daily audit diff logs (`diff-YYYYMMDD.log`) are removed after 30 days.
|
|
67
|
+
|
|
68
|
+
4. **Emergency Storage Ceiling (500 MB Cap)**:
|
|
69
|
+
If total backup directory size exceeds 500 MB, an emergency purge trims the oldest backups
|
|
70
|
+
across all files until total disk usage drops back down to 80% (400 MB).
|
|
71
|
+
|
|
72
|
+
5. **Active Vault Ejection**:
|
|
73
|
+
Files that grow beyond the MB ceiling in `sentinel_marker` are immediately removed from
|
|
74
|
+
the `.shadow_vault` to recover disk space instantly.
|
|
75
|
+
|
|
76
|
+
*The pruning engine runs automatically every 6 hours and immediately upon service startup.*
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## ๐ฆ Installation
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install shadow-sentinel
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Set up and start the systemd background service in one step:
|
|
87
|
+
```bash
|
|
88
|
+
sudo sentinel service install
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## ๐ ๏ธ Usage
|
|
94
|
+
|
|
95
|
+
### Protect a Directory
|
|
96
|
+
Navigate to the directory and run:
|
|
97
|
+
```bash
|
|
98
|
+
sentinel init 5
|
|
99
|
+
```
|
|
100
|
+
*(Creates `sentinel_marker` with a 5 MB file size limit).*
|
|
101
|
+
|
|
102
|
+
### List Backups
|
|
103
|
+
Simply type in any terminal:
|
|
104
|
+
```bash
|
|
105
|
+
sentinel
|
|
106
|
+
```
|
|
107
|
+
Automatically detects the nearest marker upwards and lists all protected revisions.
|
|
108
|
+
|
|
109
|
+
### Restore a File
|
|
110
|
+
```bash
|
|
111
|
+
sentinel restore 3
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### View Unified Diff
|
|
115
|
+
```bash
|
|
116
|
+
sentinel diff 3
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### List All Active Markers on System
|
|
120
|
+
```bash
|
|
121
|
+
sentinel markers
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Full Interactive Manual
|
|
125
|
+
```bash
|
|
126
|
+
sentinel help
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## โ๏ธ Storage & Configuration
|
|
132
|
+
|
|
133
|
+
### Linux FHS Standard Paths:
|
|
134
|
+
- **Root Daemon:** `/var/lib/shadow-sentinel/`
|
|
135
|
+
- **User Daemon:** `~/.local/share/shadow-sentinel/`
|
|
136
|
+
|
|
137
|
+
### Custom Storage Redirection (`/etc/shadow-sentinel/config.json`):
|
|
138
|
+
To store backups, vaults, and indices in a custom directory, create:
|
|
139
|
+
`/etc/shadow-sentinel/config.json` (for root) or `~/.config/shadow-sentinel/config.json` (for user):
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"storage_dir": "/custom/path/to/backups"
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Environment Variable Override:
|
|
148
|
+
```bash
|
|
149
|
+
export SENTINEL_STORAGE_DIR=/custom/path/to/backups
|
|
150
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
shadow_sentinel/__init__.py,sha256=B_3ORMUVfHwwWm_a5oolBNwNGuj0EA7lPigK3iOiP9o,263
|
|
2
|
+
shadow_sentinel/core.py,sha256=b51MKecbgHE7EMFm1PgJE6gPhx-OM1rsFl-9Fs9_1VE,35095
|
|
3
|
+
shadow_sentinel-1.0.1.dist-info/licenses/LICENSE,sha256=l1xiBsxhKiw_f2XZozn49olDjISJttxCkZ_0DPpDxJg,904
|
|
4
|
+
shadow_sentinel-1.0.1.dist-info/METADATA,sha256=TU4sl9roM2-DITlU8mmxTi_ODsZOpzVAhi0HSgvCI1g,4701
|
|
5
|
+
shadow_sentinel-1.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
shadow_sentinel-1.0.1.dist-info/entry_points.txt,sha256=3T7ru6skGVzgXSV69g1IrcMz4of0wg6Hn94WLfH33Y0,59
|
|
7
|
+
shadow_sentinel-1.0.1.dist-info/top_level.txt,sha256=CQKiZFAsDpG5gAFXLu4koFyX1T1DIXrHk86qtINY_jE,16
|
|
8
|
+
shadow_sentinel-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Creative Commons Attribution-ShareAlike 3.0 Germany (CC BY-SA 3.0 DE)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 automatician (Torsten Frieser), Germany / Saxony / Zittau.
|
|
4
|
+
|
|
5
|
+
You are free to:
|
|
6
|
+
Share โ copy and redistribute the material in any medium or format
|
|
7
|
+
Adapt โ remix, transform, and build upon the material for any purpose, even commercially.
|
|
8
|
+
|
|
9
|
+
Under the following terms:
|
|
10
|
+
Attribution โ You must give appropriate credit, provide a link to the license,
|
|
11
|
+
and indicate if changes were made. You may do so in any reasonable manner, but
|
|
12
|
+
not in any way that suggests the licensor endorses you or your use.
|
|
13
|
+
|
|
14
|
+
ShareAlike โ If you remix, transform, or build upon the material, you must
|
|
15
|
+
distribute your contributions under the same license as the original.
|
|
16
|
+
|
|
17
|
+
No additional restrictions โ You may not apply legal terms or technological measures
|
|
18
|
+
that legally restrict others from doing anything the license permits.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
shadow_sentinel
|