ui+api: dedup per-app marks by app_key; auto-refresh runtime

This commit is contained in:
beckline
2026-02-15 16:31:19 +03:00
parent b77adb153a
commit 70c5eea935
5 changed files with 206 additions and 16 deletions

View File

@@ -48,6 +48,9 @@ type appMarkItem struct {
Cgroup string `json:"cgroup"` // absolute path ("/user.slice/..."), informational Cgroup string `json:"cgroup"` // absolute path ("/user.slice/..."), informational
CgroupRel string `json:"cgroup_rel"` CgroupRel string `json:"cgroup_rel"`
Level int `json:"level"` Level int `json:"level"`
Unit string `json:"unit,omitempty"`
Command string `json:"command,omitempty"`
AppKey string `json:"app_key,omitempty"`
AddedAt string `json:"added_at"` AddedAt string `json:"added_at"`
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
} }
@@ -74,6 +77,9 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
op := TrafficAppMarksOp(strings.ToLower(strings.TrimSpace(string(body.Op)))) op := TrafficAppMarksOp(strings.ToLower(strings.TrimSpace(string(body.Op))))
target := strings.ToLower(strings.TrimSpace(body.Target)) target := strings.ToLower(strings.TrimSpace(body.Target))
cgroup := strings.TrimSpace(body.Cgroup) cgroup := strings.TrimSpace(body.Cgroup)
unit := strings.TrimSpace(body.Unit)
command := strings.TrimSpace(body.Command)
appKey := strings.TrimSpace(body.AppKey)
timeoutSec := body.TimeoutSec timeoutSec := body.TimeoutSec
if op == "" { if op == "" {
@@ -165,7 +171,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := appMarksAdd(target, inodeID, cgAbs, rel, level, ttl); err != nil { if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl); err != nil {
writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ writeJSON(w, http.StatusOK, TrafficAppMarksResponse{
OK: false, OK: false,
Op: string(op), Op: string(op),
@@ -250,7 +256,7 @@ func appMarksGetStatus() (vpnCount int, directCount int) {
return vpnCount, directCount return vpnCount, directCount
} }
func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, ttlSec int) error { func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int) error {
target = strings.ToLower(strings.TrimSpace(target)) target = strings.ToLower(strings.TrimSpace(target))
if target != "vpn" && target != "direct" { if target != "vpn" && target != "direct" {
return fmt.Errorf("invalid target") return fmt.Errorf("invalid target")
@@ -271,6 +277,27 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
st := loadAppMarksState() st := loadAppMarksState()
changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC())
unit = strings.TrimSpace(unit)
command = strings.TrimSpace(command)
appKey = normalizeAppKey(appKey, command)
// EN: Avoid unbounded growth of marks for the same app.
// RU: Не даём бесконечно плодить метки на одно и то же приложение.
if appKey != "" {
kept := st.Items[:0]
for _, it := range st.Items {
if strings.ToLower(strings.TrimSpace(it.Target)) == target &&
strings.TrimSpace(it.AppKey) == appKey &&
it.ID != id {
_ = nftDeleteAppMarkRule(target, it.ID)
changed = true
continue
}
kept = append(kept, it)
}
st.Items = kept
}
// Replace any existing rule/state for this (target,id). // Replace any existing rule/state for this (target,id).
_ = nftDeleteAppMarkRule(target, id) _ = nftDeleteAppMarkRule(target, id)
if err := nftInsertAppMarkRule(target, rel, level, id); err != nil { if err := nftInsertAppMarkRule(target, rel, level, id); err != nil {
@@ -284,6 +311,9 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
Cgroup: cgAbs, Cgroup: cgAbs,
CgroupRel: rel, CgroupRel: rel,
Level: level, Level: level,
Unit: unit,
Command: command,
AppKey: appKey,
AddedAt: now.Format(time.RFC3339), AddedAt: now.Format(time.RFC3339),
ExpiresAt: now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339), ExpiresAt: now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339),
} }
@@ -613,6 +643,22 @@ func saveAppMarksState(st appMarksState) error {
return os.Rename(tmp, trafficAppMarksPath) return os.Rename(tmp, trafficAppMarksPath)
} }
func normalizeAppKey(appKey string, command string) string {
key := strings.TrimSpace(appKey)
if key != "" {
return key
}
cmd := strings.TrimSpace(command)
if cmd == "" {
return ""
}
fields := strings.Fields(cmd)
if len(fields) > 0 {
return strings.TrimSpace(fields[0])
}
return cmd
}
func isAllDigits(s string) bool { func isAllDigits(s string) bool {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if s == "" { if s == "" {

View File

@@ -200,10 +200,15 @@ const (
// EN: Runtime app marking request. Used by per-app launcher wrappers. // EN: Runtime app marking request. Used by per-app launcher wrappers.
// RU: Runtime app marking запрос. Используется wrapper-лаунчером per-app. // RU: Runtime app marking запрос. Используется wrapper-лаунчером per-app.
type TrafficAppMarksRequest struct { type TrafficAppMarksRequest struct {
Op TrafficAppMarksOp `json:"op"` Op TrafficAppMarksOp `json:"op"`
Target string `json:"target"` // vpn|direct Target string `json:"target"` // vpn|direct
Cgroup string `json:"cgroup,omitempty"` Cgroup string `json:"cgroup,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add // EN: Optional metadata to deduplicate marks per-app across restarts / re-runs.
// RU: Опциональные метаданные, чтобы не плодить метки на одно и то же приложение.
Unit string `json:"unit,omitempty"`
Command string `json:"command,omitempty"`
AppKey string `json:"app_key,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add
} }
type TrafficAppMarksResponse struct { type TrafficAppMarksResponse struct {

View File

@@ -825,6 +825,9 @@ class ApiClient:
op: str, op: str,
target: str, target: str,
cgroup: str = "", cgroup: str = "",
unit: str = "",
command: str = "",
app_key: str = "",
timeout_sec: int = 0, timeout_sec: int = 0,
) -> TrafficAppMarksResult: ) -> TrafficAppMarksResult:
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
@@ -833,6 +836,12 @@ class ApiClient:
} }
if cgroup: if cgroup:
payload["cgroup"] = str(cgroup).strip() payload["cgroup"] = str(cgroup).strip()
if unit:
payload["unit"] = str(unit).strip()
if command:
payload["command"] = str(command).strip()
if app_key:
payload["app_key"] = str(app_key).strip()
if int(timeout_sec or 0) > 0: if int(timeout_sec or 0) > 0:
payload["timeout_sec"] = int(timeout_sec) payload["timeout_sec"] = int(timeout_sec)

View File

@@ -716,12 +716,18 @@ class DashboardController:
op: str, op: str,
target: str, target: str,
cgroup: str = "", cgroup: str = "",
unit: str = "",
command: str = "",
app_key: str = "",
timeout_sec: int = 0, timeout_sec: int = 0,
) -> TrafficAppMarksResult: ) -> TrafficAppMarksResult:
return self.client.traffic_appmarks_apply( return self.client.traffic_appmarks_apply(
op=op, op=op,
target=target, target=target,
cgroup=cgroup, cgroup=cgroup,
unit=unit,
command=command,
app_key=app_key,
timeout_sec=timeout_sec, timeout_sec=timeout_sec,
) )

View File

@@ -82,6 +82,8 @@ class TrafficModeDialog(QDialog):
self._settings = QtCore.QSettings("AdGuardVPN", "SelectiveVPNDashboardQt") self._settings = QtCore.QSettings("AdGuardVPN", "SelectiveVPNDashboardQt")
self._last_app_unit: str = str(self._settings.value("traffic_app_last_unit", "") or "") self._last_app_unit: str = str(self._settings.value("traffic_app_last_unit", "") or "")
self._last_app_target: str = str(self._settings.value("traffic_app_last_target", "") or "") self._last_app_target: str = str(self._settings.value("traffic_app_last_target", "") or "")
self._last_app_key: str = str(self._settings.value("traffic_app_last_key", "") or "")
self._last_app_cmdline: str = str(self._settings.value("traffic_app_last_cmdline", "") or "")
try: try:
self._last_app_cgroup_id: int = int(self._settings.value("traffic_app_last_cgroup_id", 0) or 0) self._last_app_cgroup_id: int = int(self._settings.value("traffic_app_last_cgroup_id", 0) or 0)
except Exception: except Exception:
@@ -468,6 +470,23 @@ RU: Применяет policy-rules и проверяет health. При оши
QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts) QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts)
QtCore.QTimer.singleShot(0, self.refresh_running_scopes) QtCore.QTimer.singleShot(0, self.refresh_running_scopes)
# EN: Auto-refresh runtime marks/units while dialog is open.
# RU: Авто-обновление runtime меток/юнитов пока окно открыто.
self._runtime_auto_timer = QtCore.QTimer(self)
self._runtime_auto_timer.setInterval(5000)
self._runtime_auto_timer.timeout.connect(self._auto_refresh_runtime)
self._runtime_auto_timer.start()
def _auto_refresh_runtime(self) -> None:
# Keep this quiet: no modal popups.
self.refresh_appmarks_counts()
try:
# Only refresh units list when Apps(runtime) tab is visible.
if int(self.tabs.currentIndex() or 0) == 1:
self.refresh_running_scopes(quiet=True)
except Exception:
pass
def _is_operation_error(self, message: str) -> bool: def _is_operation_error(self, message: str) -> bool:
low = (message or "").strip().lower() low = (message or "").strip().lower()
return ("rolled back" in low) or ("apply failed" in low) or ("verification failed" in low) return ("rolled back" in low) or ("apply failed" in low) or ("verification failed" in low)
@@ -864,6 +883,7 @@ RU: Применяет policy-rules и проверяет health. При оши
def _refresh_last_scope_ui(self) -> None: def _refresh_last_scope_ui(self) -> None:
unit = (self._last_app_unit or "").strip() unit = (self._last_app_unit or "").strip()
target = (self._last_app_target or "").strip().lower() target = (self._last_app_target or "").strip().lower()
app_key = (self._last_app_key or "").strip()
cg_id = int(self._last_app_cgroup_id or 0) cg_id = int(self._last_app_cgroup_id or 0)
parts = [] parts = []
@@ -871,6 +891,8 @@ RU: Применяет policy-rules и проверяет health. При оши
parts.append(f"unit={unit}") parts.append(f"unit={unit}")
if target in ("vpn", "direct"): if target in ("vpn", "direct"):
parts.append(f"target={target}") parts.append(f"target={target}")
if app_key:
parts.append(f"app={app_key}")
if cg_id > 0: if cg_id > 0:
parts.append(f"cgroup_id={cg_id}") parts.append(f"cgroup_id={cg_id}")
@@ -882,9 +904,19 @@ RU: Применяет policy-rules и проверяет health. При оши
self.btn_app_stop_last.setEnabled(bool(unit)) self.btn_app_stop_last.setEnabled(bool(unit))
self.btn_app_unmark_last.setEnabled(bool(unit) and target in ("vpn", "direct") and cg_id > 0) self.btn_app_unmark_last.setEnabled(bool(unit) and target in ("vpn", "direct") and cg_id > 0)
def _set_last_scope(self, *, unit: str = "", target: str = "", cgroup_id: int = 0) -> None: def _set_last_scope(
self,
*,
unit: str = "",
target: str = "",
app_key: str = "",
cmdline: str = "",
cgroup_id: int = 0,
) -> None:
self._last_app_unit = str(unit or "").strip() self._last_app_unit = str(unit or "").strip()
self._last_app_target = str(target or "").strip().lower() self._last_app_target = str(target or "").strip().lower()
self._last_app_key = str(app_key or "").strip()
self._last_app_cmdline = str(cmdline or "").strip()
try: try:
self._last_app_cgroup_id = int(cgroup_id or 0) self._last_app_cgroup_id = int(cgroup_id or 0)
except Exception: except Exception:
@@ -892,6 +924,8 @@ RU: Применяет policy-rules и проверяет health. При оши
self._settings.setValue("traffic_app_last_unit", self._last_app_unit) self._settings.setValue("traffic_app_last_unit", self._last_app_unit)
self._settings.setValue("traffic_app_last_target", self._last_app_target) self._settings.setValue("traffic_app_last_target", self._last_app_target)
self._settings.setValue("traffic_app_last_key", self._last_app_key)
self._settings.setValue("traffic_app_last_cmdline", self._last_app_cmdline)
self._settings.setValue("traffic_app_last_cgroup_id", int(self._last_app_cgroup_id or 0)) self._settings.setValue("traffic_app_last_cgroup_id", int(self._last_app_cgroup_id or 0))
self._refresh_last_scope_ui() self._refresh_last_scope_ui()
@@ -1058,24 +1092,86 @@ RU: Применяет policy-rules и проверяет health. При оши
QMessageBox.warning(self, "Missing command", "Please enter a command to run.") QMessageBox.warning(self, "Missing command", "Please enter a command to run.")
return return
app_key = ""
try:
args = shlex.split(cmdline or "")
if args:
app_key = str(args[0] or "").strip()
except Exception:
app_key = ""
if not app_key:
# Fallback: best-effort first token.
app_key = (cmdline.split() or [""])[0].strip()
target = "vpn" if self.rad_app_vpn.isChecked() else "direct" target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_app_ttl.value()) * 3600 ttl_sec = int(self.spn_app_ttl.value()) * 3600
# EN: If the app is already running inside the last svpn unit, don't spawn a new instance.
# RU: Если приложение уже запущено в последнем svpn unit, не запускаем второй экземпляр.
last_unit = (self._last_app_unit or "").strip()
last_target = (self._last_app_target or "").strip().lower()
last_key = (self._last_app_key or "").strip()
if last_unit and last_target == target and last_key and last_key == app_key:
code, out = self._systemctl_user(["is-active", last_unit])
if code == 0 and (out or "").strip().lower() == "active":
cg = self._query_control_group_for_unit(last_unit)
self._append_app_log(
f"[app] already running: app={app_key} target={target} unit={last_unit} (refreshing mark)"
)
res = self.ctrl.traffic_appmarks_apply(
op="add",
target=target,
cgroup=cg,
unit=last_unit,
command=cmdline,
app_key=app_key,
timeout_sec=ttl_sec,
)
if res.ok:
self._append_app_log(
f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s"
)
self._set_action_status(
f"App mark refreshed: target={target} cgroup_id={res.cgroup_id}",
ok=True,
)
self._set_last_scope(
unit=last_unit,
target=target,
app_key=app_key,
cmdline=cmdline,
cgroup_id=int(res.cgroup_id or 0),
)
else:
self._append_app_log(f"[appmarks] ERROR: {res.message}")
self._set_action_status(
f"App mark refresh failed: target={target} ({res.message})",
ok=False,
)
QMessageBox.critical(self, "App mark error", res.message or "unknown error")
self.refresh_appmarks_counts()
self.refresh_running_scopes()
return
unit = f"svpn-{target}-{int(time.time())}.service" unit = f"svpn-{target}-{int(time.time())}.service"
self._append_app_log( self._append_app_log(
f"[app] launching: target={target} ttl={ttl_sec}s unit={unit}" f"[app] launching: app={app_key or '-'} target={target} ttl={ttl_sec}s unit={unit}"
) )
cg, out = self._run_systemd_unit(cmdline, unit=unit) cg, out = self._run_systemd_unit(cmdline, unit=unit)
if out: if out:
self._append_app_log(f"[app] systemd-run:\n{out}") self._append_app_log(f"[app] systemd-run:\n{out}")
self._append_app_log(f"[app] ControlGroup: {cg}") self._append_app_log(f"[app] ControlGroup: {cg}")
self._set_last_scope(unit=unit, target=target, cgroup_id=0) self._set_last_scope(unit=unit, target=target, app_key=app_key, cmdline=cmdline, cgroup_id=0)
res = self.ctrl.traffic_appmarks_apply( res = self.ctrl.traffic_appmarks_apply(
op="add", op="add",
target=target, target=target,
cgroup=cg, cgroup=cg,
unit=unit,
command=cmdline,
app_key=app_key,
timeout_sec=ttl_sec, timeout_sec=ttl_sec,
) )
if res.ok: if res.ok:
@@ -1086,20 +1182,40 @@ RU: Применяет policy-rules и проверяет health. При оши
f"App mark added: target={target} cgroup_id={res.cgroup_id}", f"App mark added: target={target} cgroup_id={res.cgroup_id}",
ok=True, ok=True,
) )
self._set_last_scope(unit=unit, target=target, cgroup_id=int(res.cgroup_id or 0)) self._set_last_scope(
unit=unit,
target=target,
app_key=app_key,
cmdline=cmdline,
cgroup_id=int(res.cgroup_id or 0),
)
else: else:
self._append_app_log(f"[appmarks] ERROR: {res.message}") self._append_app_log(f"[appmarks] ERROR: {res.message}")
self._set_action_status( self._set_action_status(
f"App mark failed: target={target} ({res.message})", f"App mark failed: target={target} ({res.message})",
ok=False, ok=False,
) )
QMessageBox.critical( low = (res.message or "").lower()
self, if "cgroupv2 path fails" in low or "no such file or directory" in low:
"App mark error", QMessageBox.critical(
res.message or "unknown error", self,
) "App mark error",
(res.message or "unknown error")
+ "\n\n"
+ "EN: This usually means the app didn't stay inside the new systemd unit "
+ "(often because it was already running). Close the app completely and run again from here.\n"
+ "RU: Обычно это значит, что приложение не осталось в новом systemd unit "
+ "(часто потому что оно уже было запущено). Полностью закрой приложение и запусти снова отсюда.",
)
else:
QMessageBox.critical(
self,
"App mark error",
res.message or "unknown error",
)
self.refresh_appmarks_counts() self.refresh_appmarks_counts()
self.refresh_running_scopes()
self._safe(work, title="Run app error") self._safe(work, title="Run app error")
@@ -1292,7 +1408,7 @@ RU: Применяет policy-rules и проверяет health. При оши
except Exception: except Exception:
return 0 return 0
def refresh_running_scopes(self) -> None: def refresh_running_scopes(self, *, quiet: bool = False) -> None:
def work() -> None: def work() -> None:
units = self._list_running_svpn_units() units = self._list_running_svpn_units()
self.lst_scopes.clear() self.lst_scopes.clear()
@@ -1347,6 +1463,14 @@ RU: Применяет policy-rules и проверяет health. При оши
self.btn_scopes_stop_selected.setEnabled(has_any) self.btn_scopes_stop_selected.setEnabled(has_any)
self.btn_scopes_cleanup.setEnabled(has_any) self.btn_scopes_cleanup.setEnabled(has_any)
if quiet:
try:
work()
except Exception:
# Quiet mode: avoid modal popups on background refresh.
pass
return
self._safe(work, title="Units refresh error") self._safe(work, title="Units refresh error")
def _copy_scope_unit(self, it: QListWidgetItem) -> None: def _copy_scope_unit(self, it: QListWidgetItem) -> None: