ui: make ControlGroup query robust; drop legacy instruction files

This commit is contained in:
beckline
2026-02-15 02:08:47 +03:00
parent f74b1cf9a9
commit 11a3eb1524
5 changed files with 45 additions and 1488 deletions

View File

@@ -1,460 +0,0 @@
Окей, давай сначала аккуратно разрулим логику, а уже потом можно будет добивать конкретный код (Go + Qt).
Ты сейчас хочешь:
Нормальную модель «DNS/SmartDNS» (кто за что отвечает).
Новую вкладку DNS в дашборде, без текстовой панели, но с понятной индикацией:
запущен ли smartdns-daemon (systemd unit),
использует ли именно приложение SmartDNS или ходит напрямую на default/meta.
Логи пока оставим на второй шаг — главное, чтобы сама схема была ровная.
1. Предлагаемая модель DNS / SmartDNS
1.1. Состояния
У нас по сути три независимых штуки:
Upstreams для резолвера selective-vpn
default1, default2, meta1, meta2
лежат в dnsUpstreamsConf (как и сейчас).
SmartDNS systemd unit
юнит типа smartdns-local.service
может быть active/inactive/failed.
Режим работы резолвера selective-vpn
via_smartdns = false → резолвер ходит напрямую на default/meta.
via_smartdns = true → резолвер ходит на 127.0.0.1#6053 (SmartDNS), а тот уже сам цепочкой: локальные → bind9 → adguard-dns.
Важно: юнит SmartDNS и режим резолвера — разные вещи.
Может быть включён юнит, но приложение всё равно ходит напрямую на adguard-dns.
1.2. Где хранить режим
Вариант попроще (без изменения 100500 мест):
Заводим отдельный json в stateDir, например
dns-mode.json:
{
"via_smartdns": true
}
Go-структура:
// types.go
type DNSMode struct {
ViaSmartDNS bool `json:"via_smartdns"`
}
Помощники:
// config.go или resolver.go
const dnsModePath = stateDir + "/dns-mode.json"
func loadDNSMode() DNSMode {
data, err := os.ReadFile(dnsModePath)
if err != nil {
return DNSMode{ViaSmartDNS: false}
}
var m DNSMode
if err := json.Unmarshal(data, &m); err != nil {
return DNSMode{ViaSmartDNS: false}
}
return m
}
func saveDNSMode(m DNSMode) {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return
}
tmp := dnsModePath + ".tmp"
_ = os.WriteFile(tmp, b, 0o644)
_ = os.Rename(tmp, dnsModePath)
}
1.3. Как это зашить в резолвер
В ResolverOpts (в resolver.go) уже есть поле DNSConfigPath.
Добавляем туда флаг:
type ResolverOpts struct {
DomainsPath string
MetaPath string
StaticPath string
CachePath string
PtrCachePath string
TraceLog string
TTL int
Workers int
DNSConfigPath string
ViaSmartDNS bool
}
Там, где сейчас создаёшь ResolverOpts (в routes_update.go / autoloop.go), просто подставляешь:
mode := loadDNSMode()
opts := ResolverOpts{
// ...
DNSConfigPath: dnsUpstreamsConf,
ViaSmartDNS: mode.ViaSmartDNS,
}
И дальше в runResolverJob:
func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResult, error) {
// ...
cfg := loadDNSConfig(opts.DNSConfigPath, logf)
// если включён режим SmartDNS игнорируем default/meta из файла
if opts.ViaSmartDNS {
cfg.Default = []string{"127.0.0.1#6053"}
cfg.Meta = []string{"127.0.0.1#6053"}
if logf != nil {
logf("dns-mode: via smartdns 127.0.0.1#6053")
}
}
// дальше как было
// ...
}
Так мы гарантируем, что при включённом via_smartdns твой Go-резолвер больше не будет ходить на 94.140.* и т.п. — всё через локальный SmartDNS.
1.4. API для режима и юнита
Чтобы вкладка DNS могла всё это показать/пощёлкать, делаем два эндпойнта:
Статус
// types.go
type DNSStatusResponse struct {
ViaSmartDNS bool `json:"via_smartdns"`
UnitState string `json:"unit_state"` // "active", "inactive", "failed", "unknown"
}
// vpn_handlers.go или dns_handlers.go
func handleDNSStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
mode := loadDNSMode()
unitState := "unknown"
if out, err := ShellRunCapture("systemctl", "is-active", "smartdns-local.service"); err == nil {
unitState = strings.TrimSpace(out)
}
writeJSON(w, http.StatusOK, DNSStatusResponse{
ViaSmartDNS: mode.ViaSmartDNS,
UnitState: unitState,
})
}
Смена режима (использовать SmartDNS или нет)
type DNSModeRequest struct {
ViaSmartDNS bool `json:"via_smartdns"`
}
func handleDNSModeSet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req DNSModeRequest
if r.Body != nil {
defer r.Body.Close()
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
}
mode := loadDNSMode()
mode.ViaSmartDNS = req.ViaSmartDNS
saveDNSMode(mode)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"via_smartdns": mode.ViaSmartDNS,
})
}
Управление юнитом SmartDNS (у тебя уже что-то такое есть — можно просто нормализовать):
type SmartdnsServiceRequest struct {
Action string `json:"action"` // "start" / "stop" / "restart"
}
type SmartdnsServiceResponse struct {
UnitState string `json:"unit_state"`
}
func handleSmartdnsService(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req SmartdnsServiceRequest
if r.Body != nil {
defer r.Body.Close()
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
}
act := strings.ToLower(strings.TrimSpace(req.Action))
if act == "" {
act = "restart"
}
if act == "start" || act == "restart" || act == "stop" {
_ = ShellRun("systemctl", act, "smartdns-local.service")
}
state := "unknown"
if out, err := ShellRunCapture("systemctl", "is-active", "smartdns-local.service"); err == nil {
state = strings.TrimSpace(out)
}
writeJSON(w, http.StatusOK, SmartdnsServiceResponse{
UnitState: state,
})
}
Роуты:
mux.HandleFunc("/api/v1/dns/status", handleDNSStatus)
mux.HandleFunc("/api/v1/dns/mode", handleDNSModeSet)
mux.HandleFunc("/api/v1/dns/smartdns-service", handleSmartdnsService)
2. Новая вкладка DNS в Qt
Теперь UI можно сделать максимально простым:
2.1. Как будет выглядеть
Группа 1 — Upstreams
4 поля: default1, default2, meta1, meta2
Кнопка «Save upstreams»
Группа 2 — SmartDNS
Чекбокс: Use SmartDNS (127.0.0.1:6053) → это via_smartdns
Лейбл: SmartDNS unit: active/inactive/failed
Лейбл: Resolver mode: via SmartDNS / direct upstreams
(опционально) две кнопки — Start / Stop юнит
Нижняя текстовая панель полностью убирается — всё, что касается логов, уже есть на вкладке Trace (режим mode=smartdns и mode=full).
2.2. Код виджета (переписанный _build_tab_dns)
В vpn_dashboard_qt.py вместо старой _build_tab_dns можно вот так (без учёта импортов — у тебя уже есть):
def _build_tab_dns(self):
tab = QWidget()
main_layout = QVBoxLayout(tab)
# ---------------- Upstreams ----------------
grp_up = QGroupBox("Upstreams")
up_layout = QFormLayout(grp_up)
self.dns_default1 = QLineEdit()
self.dns_default2 = QLineEdit()
self.dns_meta1 = QLineEdit()
self.dns_meta2 = QLineEdit()
up_layout.addRow("default1", self.dns_default1)
up_layout.addRow("default2", self.dns_default2)
up_layout.addRow("meta1", self.dns_meta1)
up_layout.addRow("meta2", self.dns_meta2)
btn_save = QPushButton("Save upstreams")
btn_save.clicked.connect(self.on_save_upstreams_clicked)
up_layout.addRow(btn_save)
# ---------------- SmartDNS ----------------
grp_smartdns = QGroupBox("SmartDNS")
sd_layout = QVBoxLayout(grp_smartdns)
self.chk_dns_via_smartdns = QCheckBox("Use SmartDNS (127.0.0.1:6053)")
self.chk_dns_via_smartdns.stateChanged.connect(self.on_dns_mode_changed)
self.lbl_smartdns_unit = QLabel("SmartDNS unit: unknown")
self.lbl_dns_mode = QLabel("Resolver mode: unknown")
btn_row = QHBoxLayout()
self.btn_smartdns_start = QPushButton("Start unit")
self.btn_smartdns_stop = QPushButton("Stop unit")
self.btn_smartdns_start.clicked.connect(
lambda: self.on_smartdns_unit_action("start")
)
self.btn_smartdns_stop.clicked.connect(
lambda: self.on_smartdns_unit_action("stop")
)
btn_row.addWidget(self.btn_smartdns_start)
btn_row.addWidget(self.btn_smartdns_stop)
btn_row.addStretch(1)
sd_layout.addWidget(self.chk_dns_via_smartdns)
sd_layout.addWidget(self.lbl_smartdns_unit)
sd_layout.addWidget(self.lbl_dns_mode)
sd_layout.addLayout(btn_row)
# ---------------- Compose ----------------
main_layout.addWidget(grp_up)
main_layout.addWidget(grp_smartdns)
main_layout.addStretch(1)
self.tab_dns = tab
self.tabs.addTab(tab, "DNS")
2.3. Обновление вкладки (refresh_dns_tab)
def refresh_dns_tab(self):
# 1) upstreams
ups = self.c.dns_upstreams_view() # как и было
self.dns_default1.setText(ups.default1 or "")
self.dns_default2.setText(ups.default2 or "")
self.dns_meta1.setText(ups.meta1 or "")
self.dns_meta2.setText(ups.meta2 or "")
# 2) статус DNS / SmartDNS
st = self.c.dns_status_view() # новый метод в контроллере
# режим
self.chk_dns_via_smartdns.blockSignals(True)
self.chk_dns_via_smartdns.setChecked(bool(st.via_smartdns))
self.chk_dns_via_smartdns.blockSignals(False)
mode_txt = "via SmartDNS" if st.via_smartdns else "direct upstreams"
self.lbl_dns_mode.setText(f"Resolver mode: {mode_txt}")
# юнит
self.lbl_smartdns_unit.setText(f"SmartDNS unit: {st.unit_state or 'unknown'}")
# немного UX: если юнит inactive, кнопка Start активна, Stop — серый
is_active = (st.unit_state == "active")
self.btn_smartdns_start.setEnabled(not is_active)
self.btn_smartdns_stop.setEnabled(is_active)
2.4. Обработчики
def on_save_upstreams_clicked(self):
ups = self.c.dns_upstreams_view()
ups.default1 = self.dns_default1.text().strip()
ups.default2 = self.dns_default2.text().strip()
ups.meta1 = self.dns_meta1.text().strip()
ups.meta2 = self.dns_meta2.text().strip()
ok, err = self.c.dns_upstreams_save(ups)
if not ok:
QMessageBox.critical(self, "Error", f"Failed to save upstreams:\n{err}")
else:
self.show_status("DNS upstreams saved")
def on_dns_mode_changed(self, state: int):
via = (state == Qt.Checked)
ok, st, err = self.c.dns_mode_set(via)
if not ok:
QMessageBox.critical(self, "Error", f"Failed to change DNS mode:\n{err}")
# откатываем чекбокс
self.refresh_dns_tab()
return
# обновляем лейблы
self.refresh_dns_tab()
def on_smartdns_unit_action(self, action: str):
ok, st, err = self.c.smartdns_service_action(action)
if not ok:
QMessageBox.critical(self, "Error", f"SmartDNS {action} failed:\n{err}")
return
self.refresh_dns_tab()
self.refresh_status_tab() # если хочешь синхронизировать индикатор на Status
2.5. Методы в dashboard_controller.py
Нужно добавить:
@dataclass
class DNSStatus:
via_smartdns: bool
unit_state: str
class DashboardController:
# ...
def dns_status_view(self) -> DNSStatus:
data = self.client.get_json("/api/v1/dns/status")
return DNSStatus(
via_smartdns=bool(data.get("via_smartdns")),
unit_state=data.get("unit_state") or "unknown",
)
def dns_mode_set(self, via: bool) -> tuple[bool, DNSStatus | None, str | None]:
try:
data = self.client.post_json(
"/api/v1/dns/mode",
{"via_smartdns": bool(via)},
)
st = self.dns_status_view()
return True, st, None
except Exception as e:
return False, None, str(e)
def smartdns_service_action(self, action: str) -> tuple[bool, str | None, str | None]:
try:
data = self.client.post_json(
"/api/v1/dns/smartdns-service",
{"action": action},
)
return True, data.get("unit_state"), None
except Exception as e:
return False, None, str(e)
Upstreams-методы у тебя уже есть, их трогать не нужно.
3. Что дальше с логами
Когда эта схема заведётся (юнит живёт своей жизнью, резолвер знает via_smartdns и ходит только на 127.0.0.1), дальше можно:
В digA логировать отдельно:
dns smartdns когда ViaSmartDNS == true,
dns direct для прямых upstream.
В handleTraceJSON(mode="gui") продолжать выкидывать «шум» вроде больших дампов резолвера.
Для mode=smartdns показывать только строки, где есть smartdns в префиксе — и там уже будет видно цепочку.
Если хочешь, в следующем шаге можем прям точечно добить:
исправление поля ViaSmartDNS bool в types.go (я видел, что у тебя там раздралось на ViaSmartDN S),
аккуратно переписать digA-лог, чтобы не было этих сообщений вида dns error ... via 94.140.14.14: lookup ... on 192.168.50.10:53, когда на самом деле всё ок.

View File

@@ -1,565 +0,0 @@
Ок, давай разберём по двум пунктам:
Финальные имена и что именно править в коде
Почему стало ~590 IP вместо привычных ~1700
1. Имена / где что менять
Чтобы не было зоопарка, фиксируем одну схему:
внутренний конфиг резолвера:
// types.go
type DNSMode string
const (
DNSModeDirect DNSMode = "direct" // ходим напрямую на default/meta
DNSModeSmartOnly DNSMode = "smartdns" // всё через SmartDNS
)
type dnsConfig struct {
Default []string `json:"default"`
Meta []string `json:"meta"`
SmartDNS string `json:"smartdns"`
Mode DNSMode `json:"mode"`
}
Важно: одно имя — dnsConfig, одно поле режима — Mode.
Никаких ViaSmartDNS, ViaSmartDnS и т.п. только Mode.
1.1. loadDNSConfig (используется резолвером)
Внутри dns_settings.go (или там, где у тебя сейчас чтение /etc/selective-vpn/dns-upstreams.conf) логика должна отдавать именно такой dnsConfig:
func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig {
cfg := dnsConfig{
Default: []string{defaultDNS1, defaultDNS2},
Meta: []string{defaultMeta1, defaultMeta2},
SmartDNS: "",
Mode: DNSModeDirect,
}
data, err := os.ReadFile(path)
if err != nil {
// файла нет просто дефолты
return cfg
}
var def, meta []string
mode := DNSModeDirect
smart := ""
lines := strings.Split(string(data), "\n")
for _, ln := range lines {
s := strings.TrimSpace(ln)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
parts := strings.Fields(s)
if len(parts) < 2 {
continue
}
key := strings.ToLower(parts[0])
vals := parts[1:]
switch key {
case "default":
def = append(def, vals...)
case "meta":
meta = append(meta, vals...)
case "smartdns":
if len(vals) > 0 {
smart = vals[0]
}
case "mode":
switch DNSMode(vals[0]) {
case DNSModeSmartOnly:
mode = DNSModeSmartOnly
default:
mode = DNSModeDirect
}
}
}
if len(def) > 0 {
cfg.Default = def
}
if len(meta) > 0 {
cfg.Meta = meta
}
if smart != "" {
cfg.SmartDNS = smart
}
cfg.Mode = mode
if logf != nil {
if cfg.Mode == DNSModeSmartOnly && cfg.SmartDNS != "" {
logf("dns-config: mode=smartdns smartdns=%s", cfg.SmartDNS)
} else {
logf("dns-config: mode=direct default=%v meta=%v", cfg.Default, cfg.Meta)
}
}
return cfg
}
1.2. runResolverJob — как он выбирает режим
Твой кусок:
cfg := loadDNSConfig(opts.DNSConfigPath, logf)
if logf != nil {
if cfg.ViaSmartDNS {
logf("resolver dns mode: SmartDNS-only (%v)", cfg.Default)
} else {
logf("resolver dns mode: direct default=%v meta=%v", cfg.Default, cfg.Meta)
}
}
Нужно заменить на:
cfg := loadDNSConfig(opts.DNSConfigPath, logf)
if logf != nil {
if cfg.Mode == DNSModeSmartOnly && cfg.SmartDNS != "" {
logf("resolver dns mode: SmartDNS-only (%s)", cfg.SmartDNS)
} else {
logf("resolver dns mode: direct default=%v meta=%v", cfg.Default, cfg.Meta)
}
}
1.3. Как резолвер реально ходит в DNS
В resolveHostGo сейчас у тебя примерно так (я по смыслу):
func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, logf func(string, ...any)) ([]string, int) {
useMeta := false
for _, m := range metaSpecial {
if host == m {
useMeta = true
break
}
}
dnsList := cfg.Default
if useMeta {
dnsList = cfg.Meta
}
ips, errs := digA(host, dnsList, 3*time.Second, logf)
...
}
Делаем так, чтобы при включённом SmartDNS всегда шли только на него:
func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, logf func(string, ...any)) ([]string, int) {
useMeta := false
for _, m := range metaSpecial {
if host == m {
useMeta = true
break
}
}
var dnsList []string
if cfg.Mode == DNSModeSmartOnly && cfg.SmartDNS != "" {
// ВСЁ через SmartDNS
dnsList = []string{cfg.SmartDNS}
} else if useMeta {
dnsList = cfg.Meta
} else {
dnsList = cfg.Default
}
ips, errs := digA(host, dnsList, 3*time.Second, logf)
...
}
И для PTR-запросов (resolveStaticLabels → digPTR) — аналогично:
func resolveStaticLabels(entries [][3]string, cfg dnsConfig, ptrCache map[string]any, ttl int, logf func(string, ...any)) (map[string][]string, int, int) {
...
dnsForPtr := ""
if cfg.Mode == DNSModeSmartOnly && cfg.SmartDNS != "" {
dnsForPtr = cfg.SmartDNS
} else if len(cfg.Default) > 0 {
dnsForPtr = cfg.Default[0]
} else {
dnsForPtr = defaultDNS1
}
...
}
1.4. routes_update.go — что именно должно быть
Там, где запускается резолвер (что-то вроде runResolverJob), должно быть только это (без своих конфигов):
opts := ResolverOpts{
DomainsPath: domainDir + "/bases.txt",
MetaPath: domainDir + "/meta-special.txt",
StaticPath: staticIPsFile,
CachePath: stateDir + "/resolver-cache.json",
PtrCachePath: stateDir + "/resolver-ptr-cache.json",
TraceLog: traceLogPath,
TTL: 24 * 3600, // или твой config.ResolverTTLSeconds
Workers: 200, // или config.ResolverWorkers
DNSConfigPath: dnsUpstreamsConf, // ВАЖНО: один путь, один формат
}
res, err := runResolverJob(opts, logf)
Никаких других структур / полей для DNS в этом файле не нужно.
Вся магия DNS живёт в dns_settings.go + resolver.go.
2. Почему теперь 590 IP, а не 1700
Смотрим на лог:
summary: domains=14864, cache_hits=2 resolved_now=1281 unresolved=13581 static_entries=294 ...
unique_ips=590 ...
Это говорит о трёх вещах:
Резолвер реально прошёлся по всем ~15k доменам.
Ответы он получил только по ~1281 домену.
Из них после фильтра isPrivateIPv4 осталось 590 уникальных публичных IP.
Что поменялось по сравнению с прошлой версией:
2.1. Всё теперь идёт ТОЛЬКО через SmartDNS
Раньше схема была примерно:
резолвер → напрямую на 94.140.* + 46.243.*
SmartDNS жил своей жизнью как локальный помощник
Сейчас при Use SmartDNS for resolver:
резолвер → только на 127.0.0.1#6053
SmartDNS → дальше сам ходит на 192.168.50.10:53 (bind) → AdGuard DNS и т.д.
Поэтому:
все блокировки / NXDOMAIN / подмена на 0.0.0.0 / 127.0.0.1 происходят до нашего Go-кода;
Go-резолвер видит либо нормальный внешний IP, либо 0.0.0.0/локалку/ошибку.
2.2. Мы фильтруем все приватные и мусорные IP
В runResolverJob:
if len(rawIps) > 0 && ts > 0 && now-int(ts) <= ttl {
for _, v := range rawIps {
if s, ok := v.(string); ok && s != "" && !isPrivateIPv4(s) {
ips = append(ips, s)
}
}
}
...
if isPrivateIPv4(ip) {
continue
}
А isPrivateIPv4 считает приватными:
0.x.x.x
10.x.x.x
127.x.x.x
192.168.x.x
172.1631.x.x
и вообще всё, что невалидное
То есть:
если AdGuard/SmartDNS возвращает 0.0.0.0 или 127.0.0.1 для трекеров / рекламы,
мы их больше не учитываем вообще;
раньше, когда ходили мимо SmartDNS, таких «мусорных» ответов было гораздо меньше.
Отсюда очень понятная картина:
1700 IP ранее = реальные IP + всякий мусор (0.0.0.0, локалки, возможно ещё что-то);
590 IP сейчас = только реальные публичные адреса, которые реально нужно засунуть в ipset и гонять через VPN.
2.3. Что с ошибками в логах вида ... on 192.168.50.10:53
Это просто текст ошибки из SmartDNS/bind, который пробрасывается наружу:
ptr error 104.26.10.38 via 127.0.0.1:6053: lookup 38.10.26.104.in-addr.arpa. on 192.168.50.10:53: no such host
мы обращаемся к 127.0.0.1:6053;
SmartDNS дальше ходит на 192.168.50.10:53 и говорит «no such host»;
net.Resolver формирует такое красивое сообщение.
То есть Go-код SmartDNS не обходит, всё как раз правильно идёт через него.
Если хочешь «как раньше» по цифрам
Если прям хочется снова видеть ~1700 IP (включая мусор):
Можно временно ослабить фильтр:
func isPrivateIPv4(ip string) bool {
// В тесте можно не резать 0.0.0.0 / 127.x.x.x и т.п.,
// просто вернуть false и посмотреть цифры
return false
}
Или сделать две метрики:
unique_ips_total — с учётом всего, что вернул DNS;
unique_ips_public — после фильтра приватных;
и логировать обе.
Но с точки зрения селективных маршрутов тебе как раз нужны только публичные IP, так что текущие 590 очень вероятно ближе к «правильной» картине.
Если хочешь, в следующем шаге могу:
дать патч для логов, чтобы в summary было:
unique_ips_public, unique_ips_private, blocked_0_ips и т.д.,
плюс подсветить в Trace вкладке отдельной строкой, сколько доменов SmartDNS убил в ноль.
в том то и дело, что теперь половина доменов которые железно работали, не работают теперь, что то изменилось, не понимаю что, у нас кеш пишется? что же не так ... и кстати логи по прежнему не верно отображается
Перестала думать
в том то и дело, что теперь половина доменов которые железно работали, не работают теперь, что то изменилось, не понимаю что, у нас кеш пишется? что же не так ... и кстати логи по прежнему не верно отображается
Думал на протяжении 2m 20s
Окей, давай по порядку, что видно из скрина логов, и что мы реально поменяли в коде.
⚠️ Сначала техническая ремарка
Часть файлов, которые ты раньше заливал (resolver.go, config.go и т.д.), у меня уже недоступны система их подчищает со временем. Я помню общую структуру и последние куски, которые ты присылал в текст, но если захочешь, чтобы я дал прям точные патчи построчно по актуальному resolver.go / dns_settings.go, лучше закинуть их ещё раз.
1. Что сейчас не так по логам
На твоём скрине снизу была строчка типа:
dns_errors=13295 unresolved=13581 unique_ips=590 ...
И вверху куча:
dns warn www.twitterinc.com via 127.0.0.1:6053:
lookup www.twitterinc.com on 192.168.50.53:53: i/o timeout
...
dns warn www.twitterstat.us via 127.0.0.1:6053:
dial udp 127.0.0.1:6053: i/o timeout
Это говорит о простом:
Домены в большинстве случаев не резолвятся вообще.
Не из-за кэша, а из-за того, что SmartDNS/BIND/AdGuard под нагрузкой просто не отвечает (таймауты).
Из-за этого у нас:
resolved_now маленький
dns_errors огромный
unique_ips ≈ 500+, хотя раньше было ≈ 1700.
То есть лог честный: с точки зрения нашего Go-резолвера для этих доменов реально "no IPs".
2. Что именно изменилось в логике DNS
До рефакторинга:
наш resolver.go бился напрямую в default1/default2/meta1/meta2 (типа 94.140.14.15 и т.д.);
SmartDNS жил сам по себе и занимался вайлдкарт-ipsetами.
После рефакторинга (который мы как раз и делали по твоей просьбе):
если отмечено «Use SmartDNS for resolver» — мы:
подменяем cfg.Default = []string{ smartdnsAddr } (127.0.0.1#6053);
и всё резолвим через SmartDNS;
параллельно мы оставили очень агрессивный пул: workers = 200, domains ≈ 15k.
И получается ситуация:
15 000 доменов × 200 воркеров × таймаут 3 секунды → местный SmartDNS + BIND просто не успевает, начинает дропать/таймаутить, мы видим кучу i/o timeout, и в итоге получаем только ~590 IP.
То есть сломали не кэш, а заддосили свой же локальный resolver 😅
3. Кэш жив он или нет?
Да, по коду (который ты кидал):
domainCache := loadJSONMap(opts.CachePath)
ptrCache := loadJSONMap(opts.PtrCachePath)
...
domainCache[host] = map[string]any{
"ips": r.ips,
"last_resolved": now,
}
...
res.DomainCache = domainCache
res.PtrCache = ptrCache
...
saveJSON(res.DomainCache, opts.CachePath)
saveJSON(res.PtrCache, opts.PtrCachePath)
Кэш:
читается в начале (loadJSONMap);
обновляется только для успешных доменов;
сохраняется в конце.
Проблема в другом: когда у тебя за один прогон 13k DNS-ошибок, в кэш почти нечего писать — большая часть доменов вообще не получила IP, и в следующий прогон они снова идут "с нуля".
4. Что я бы сделал сейчас в коде (минимально и по делу)
4.1. Ограничить параллелизм при работе через SmartDNS
В runResolverJob (там, где ты сейчас считаешь workers), сделай так:
workers := opts.Workers
if workers <= 0 {
workers = 200
}
// если работаем через SmartDNS не лупим по нему 200 горутин
if cfg.ViaSmartDNS && workers > 50 {
workers = 50
}
// safety clamps
if workers < 1 {
workers = 1
}
if workers > 500 {
workers = 500
}
if logf != nil {
logf("resolver start: domains=%d ttl=%ds workers=%d viaSmartDNS=%v",
len(domains), ttl, workers, cfg.ViaSmartDNS)
}
Суть:
при прямых DNS — всё как раньше (200 воркеров нормально жрут 94.140.*);
при SmartDNS — ставим жёсткий потолок, например 50 (можно 30-80 поэкспериментировать).
Это радикально уменьшит количество одновременных запросов к SmartDNS/BIND, и таймаутов станет сильно меньше → dns_errors упадут, unique_ips вырастут.
4.2. (Опционально) Увеличить таймаут для SmartDNS
Сейчас digA вызывается так (по прошлому коду):
ips, errs := digA(host, dnsList, 3*time.Second, logf)
Если хочешь мягко подстраховаться:
timeout := 3 * time.Second
if cfg.ViaSmartDNS {
timeout = 5 * time.Second
}
ips, errs := digA(host, dnsList, timeout, logf)
Для этого нужно просто протащить cfg или флажок viaSmartDNS до resolveHostGo:
func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, viaSmartDNS bool, logf func(string, ...any)) ([]string, int) {
...
timeout := 3 * time.Second
if viaSmartDNS {
timeout = 5 * time.Second
}
ips, errs := digA(host, dnsList, timeout, logf)
...
}
И в runResolverJob вызывать:
ips, errs := resolveHostGo(j.host, cfg, metaSpecial, cfg.ViaSmartDNS, logf)
Это не обязательно, но в связке с уменьшением workers даст более стабильную картину.
5. Почему лог «неверно отображается»
Тут важно разделить:
То, что реально делает наш резолвер.
Если он пишет:
dns warn www.twitterinc.com via 127.0.0.1:6053: dial udp 127.0.0.1:6053: i/o timeout
это значит, что конкретно этот запрос реально упал по таймауту.
Даже если "в браузере всё открывается", это уже другие запросы, в другое время и с другим паттерном (не 15k доменов за раз).
Фильтрация на вкладке Events/SmartDNS/Full.
Мы уже делали фильтр для mode=gui, чтобы убрать:
[routes], resolver, dnstt, smartdns, nft add element, cache hit и т.п. шум.
Если сейчас в режиме Events ты всё равно видишь этот трэш с [routes] dns warn ... — значит, в handleTraceJSON в ветке case "gui": надо ещё сильнее ужать фильтр, например:
if strings.HasPrefix(ll, "[routes]") ||
strings.Contains(ll, " dns warn ") ||
strings.Contains(ll, " dns error ") {
continue
}
Тогда:
весь подробный DNS-дебаг остаётся в Full и SmartDNS;
Events показывает только человеческие штуки [gui], [login], [vpn], [error] и т.д.
Если хочешь, чтобы я точно подогнал фильтр под твой текущий trace_handlers.go, просто перекинь файл ещё раз — старая версия у меня уже протухла.
6. Что бы я сделал дальше по шагам
Внести правку с ограничением workers при ViaSmartDNS.
(Опционально) Протянуть флаг viaSmartDNS в resolveHostGo и увеличить таймаут до 5s.
Запустить update-selective-routes и посмотреть:
строку summary (особенно dns_errors и unique_ips);
стало ли больше IP (должно быть ближе к твоим старым ~1700, хотя не обязательно 1:1).
Если с IP всё норм, но Events всё ещё замусорен подправить фильтр в handleTraceJSON (mode = "gui").
Если хочешь, чтобы я прям по твоим актуальным файлам выписал патчи diffом (resolver.go, dns_settings.go, trace_handlers.go) скинь их ещё раз, я аккуратно вставлю всё в нужные места без переименования функций и с теми же структурами, что уже есть.

View File

@@ -1,201 +0,0 @@
Инструкция 3 (safe-версия)
Цель:
Сделать улучшения резолвера без risky-переписывания, сохранить обратную совместимость с текущим GUI/API,
и убрать главные источники шума: массовые NXDOMAIN, таймауты и нечитабельные метрики.
--------------------------------------------------------------------
0) Что подтверждено по текущему состоянию
--------------------------------------------------------------------
1. Основной рабочий код сейчас в:
- app/resolver.go
- app/routes_update.go
- app/dns_settings.go
- app/domains_handlers.go
2. Логи показывают:
- много NXDOMAIN (ожидаемо при широком base x subs)
- заметную долю timeout
- один агрегированный счетчик dns_errors, из-за чего трудно понять причину деградации.
3. Формат dns upstream у нас: host#port (например 94.140.14.15#53 или 127.0.0.1#6053).
Это важно: нельзя использовать валидацию, которая принимает только host:port.
--------------------------------------------------------------------
1) Архитектурное решение (рекомендуемое)
--------------------------------------------------------------------
Не оставлять только переключатель direct <-> smartdns.
Сделать 3 режима резолвера:
- direct:
обычные домены через default/meta upstream.
- smartdns:
все домены через SmartDNS address.
- hybrid_wildcard (recommended):
только wildcard-домены через SmartDNS, остальные напрямую через default/meta.
Почему так лучше:
- сохраняем скорость и отказоустойчивость direct для обычных доменов;
- wildcard-логику держим строго в SmartDNS, как ты и хотел;
- не ломаем текущий UX: можно оставить старый bool и маппить его на mode.
--------------------------------------------------------------------
2) Что НЕ внедряем из старой инструкция3
--------------------------------------------------------------------
1. Не используем netip.ParseAddrPort для upstream-валидации (ломает host#port).
2. Не используем netip.MustParseAddr в hot path (может паниковать).
3. Не добавляем лишний semaphore поверх worker pool (сложность без явной выгоды).
4. Не делаем агрессивный рефактор API-контрактов без backward-compat.
--------------------------------------------------------------------
3) Пакет безопасных правок (приоритет P1)
--------------------------------------------------------------------
P1.1 - Режимы DNS (backward compatible)
Файлы:
- app/types.go
- app/dns_settings.go
- app/routes_update.go
- app/resolver.go
Изменения:
1) Ввести enum режима:
type DNSResolverMode string
const (
DNSModeDirect DNSResolverMode = "direct"
DNSModeSmartDNS DNSResolverMode = "smartdns"
DNSModeHybrid DNSResolverMode = "hybrid_wildcard"
)
2) Расширить DNSMode/DNSStatusResponse/DNSModeRequest полем Mode,
но оставить ViaSmartDNS для старого GUI:
- если Mode пустой, использовать ViaSmartDNS:
- true -> smartdns
- false -> direct
3) В ResolverOpts передавать Mode и список wildcard-доменов (один раз на job).
4) В resolveHostGo выбирать dnsList так:
- mode == smartdns: []{smartdnsAddr}
- mode == hybrid_wildcard и host совпал с wildcard: []{smartdnsAddr}
- иначе: meta или default по текущей логике.
Примечание:
Wildcard-список уже хранится в smartdns-wildcards.json через /api/v1/smartdns/wildcards.
Нужно только использовать его в резолвере.
P1.2 - Upstream fallback с классификацией ошибок
Файл:
- app/resolver.go
Изменения:
1) В digA:
- идти по upstream последовательно;
- timeout/temporary -> fallback на следующий upstream;
- nxdomain -> остановить попытки для домена (дальше пробовать бессмысленно).
2) Классифицировать ошибки через net.DNSError + fallback по тексту:
- nxdomain
- timeout
- temporary
- other
3) Вместо одного dns_errors вести структуру счетчиков:
dns_attempts, dns_ok, dns_nxdomain, dns_timeout, dns_temporary, dns_other.
P1.3 - Разделенные метрики в summary
Файл:
- app/resolver.go
Изменения:
1) Обновить финальный лог "resolve summary" с раздельными счетчиками DNS-ошибок.
2) Добавить per-upstream агрегаты (минимум attempts/ok/timeout/nxdomain/other).
Формат может быть одной строкой JSON, чтобы GUI/анализатору было проще парсить.
P1.4 - Ограничение domain expansion
Файл:
- app/routes_update.go
Изменения:
1) Добавить конфиг-лимиты через env:
- RESOLVE_SUBS_PER_BASE_LIMIT (например default 25)
- RESOLVE_DOMAINS_HARD_CAP (например default 12000)
2) После построения domainSet:
- сортировать домены;
- при превышении hard cap обрезать хвост детерминированно;
- писать явный warning в trace.
3) Логировать breakdown:
bases_count, subs_count, expanded_count, total_domains.
--------------------------------------------------------------------
4) Пакет улучшений P2 (после P1)
--------------------------------------------------------------------
P2.1 - Negative cache
Файл:
- app/resolver.go
Идея:
- кэшировать nxdomain/servfail на короткий TTL (например 10-20 минут),
чтобы не долбить одинаковые несуществующие имена каждую прогонку.
P2.2 - PTR retry (ограниченный)
Файл:
- app/resolver.go
Идея:
- для digPTR сделать 1-2 retry только на timeout/temporary;
- не ретраить nxdomain.
P2.3 - GUI/API отображение режима
Файлы GUI:
- selective-vpn-gui/api_client.py
- selective-vpn-gui/dashboard_controller.py
- selective-vpn-gui/vpn_dashboard_qt.py
Идея:
- показать mode = direct/smartdns/hybrid_wildcard;
- оставить старый toggle рабочим (маппинг direct/smartdns),
а hybrid можно добавить как отдельный выбор (позже).
--------------------------------------------------------------------
5) Порядок внедрения (рекомендуемый)
--------------------------------------------------------------------
Шаг 1:
Реализовать режимы + fallback + split-метрики (P1.1, P1.2, P1.3).
Шаг 2:
Добавить лимиты expansion (P1.4), проверить изменение unresolved/timeouts.
Шаг 3:
Добавить negative cache и PTR retry (P2.1, P2.2).
--------------------------------------------------------------------
6) Критерии готовности
--------------------------------------------------------------------
1. В trace видно не один dns_errors, а раздельные причины.
2. В режиме direct при падении одного upstream запросы частично продолжают проходить через fallback.
3. В режиме hybrid_wildcard wildcard-домены уходят через SmartDNS, остальные через direct.
4. Количество timeout и общее время прогона снижаются относительно текущего baseline.
--------------------------------------------------------------------
7) Короткий диагноз по сути
--------------------------------------------------------------------
Проблема не в одном SmartDNS bottleneck.
Основной вклад сейчас дает комбинация:
- большой domain expansion,
- много несуществующих имен (NXDOMAIN),
- и отсутствие четкой стратегии fallback/метрик.
Safe-путь:
ввести гибридный routing DNS + раздельную диагностику + контролируемый expansion.

View File

@@ -1,252 +0,0 @@
Анализ твоего кода resolver.go
Я внимательно прошёлся по всему коду: структура хорошая (модульный, с логами, кэшем, concurrent резолвом), логика правильная для задачи (домены → IPs с meta/default upstream'ами, static с PTR-лейблами, приватные IP фильтруем). Это уже рабочий инструмент, который может обрабатывать тысячи доменов без сбоев. Но да, есть потенциальные проблемы и места для улучшений — некоторые очевидные (edge-кейсы), другие — для масштаба/надёжности.
Я разделил на потенциальные поломки (где код может крашнуться или дать неверный результат), слабые места (не поломки, но риски в проде) и рекомендации с кодовыми примерами. Всё основано на твоём коде — не выдумываю, а анализирую.
1. Потенциальные поломки (где код может упасть или дать мусор)
Конфликт портов в upstream (в resolveHostGo и digPTR):
splitDNS возвращает port (e.g., "127.0.0.1#6053" → "127.0.0.1", "6053").
Но в net.Resolver.Dial ты используешь net.JoinHostPort(server, port) — это ок, но Resolver по дефолту использует UDP. Если upstream требует TCP (e.g., большие ответы >512 байт) — запрос fail (dns.exception.SyntaxError или timeout).
Поломка: Для доменов с большим TXT/NS — no IPs, но errs++ (не обновит кэш).
Тест: Резолв домен с большим ответом (e.g., "example.com" TXT) на upstream с port. Если fail — вот оно.
Timeout слишком короткий (3s в ctx):
На медленных upstream'ах (e.g., DNS over Tor или VPN) — запрос не успеет, вернёт [] и errs++.
Поломка: Unresolved домены, даже если upstream живой. При повторном запуске — из кэша не возьмёт (потому что не обновил).
Тест: Установи медленный DNS (e.g., 8.8.8.8 с delay) — увидишь dns.timeout.
PTR fail без retry (в resolveStaticLabels):
digPTR без retry — если upstream временно down, PTR не получит, и лейбл = "*[STATIC-IP]".
Поломка: Если все upstream fail — все static без PTR, но код не крашится. В проде — потеря лейблов.
Тест: Установи invalid upstream — увидишь ptr_errors++, но код продолжит.
loadDNSConfig fallback на дефолт без валидации:
Если файл битый (e.g., "default abc#invalid") — fallback на дефолт, но дефолт может не работать.
Поломка: Silent fail — резолв будет работать на старых дефолтах, но юзер не поймёт почему.
Тест: Сделай файл с кривыми upstream'ами — увидишь в логах fallback, но без ошибки.
uniqueStrings и map в IPMap — но без сортировки в map:
IPMap использует map[string]struct{} для dedup, но при append в res.IPMap — sort.Slice по IP, но лейблы внутри не отсортированы.
Поломка: Не поломка, но нестабильный порядок лейблов в mapLines — если GUI полагается на порядок.
Тест: Несколько лейблов на IP — порядок random.
isPrivateIPv4 не обрабатывает /mask в IP:
Проверяет только base IP, но если ip = "10.0.0.0/8" — parts = ["10.0.0.0/8"] → len!=4 → return true (ок). Но если mask invalid — не catch.
Поломка: Если static с кривым CIDR (e.g., "1.2.3.4/33") — пропустит как non-private.
Тест: Добавь "1.2.3.4/33" в static — пройдёт, хотя invalid.
runResolverJob: если workers=0 или ttl=0 — не краш, но дефолты
Ты clamp'ишь (ttl 60..86400, workers 1..500) — хорошо, но если envInt fail — silent.
Поломка: Не поломка, но если ENV кривой — silent fallback.
2. Слабые места (не поломки, но риски в проде)
Concurrent резолв без rate-limit:
80 workers + 500+ доменов — может flood upstream'ы (DNS rate-limit ban).
Риск: Upstream забанит IP, все запросы fail.
Нет IPv6:
Всё только IPv4 (A-записи, isPrivateIPv4). Если домен только AAAA — no IPs.
Риск: Современные сайты (e.g., google) имеют IPv6 — потеряешь трафик.
Кэш без invalidation:
Кэш на TTL, но если upstream сменился — старый кэш может быть invalid.
Риск: После смены DNS — старые IPs в кэше до TTL.
PTR без кэша ошибок:
Если PTR fail — всё равно пишем в кэш empty names. Следующий раз — из кэша empty.
Риск: Временный fail upstream — навсегда потерянные лейблы до ручного clear cache.
Логи только в файл:
appendTraceLine — ok, но если файл locked — silent fail.
Риск: В многопоточном режиме — race на append.
envInt без дефолта fallback:
Если ENV empty — strconv.Atoi("") → error, но ты используешь default. Ok, но можно сделать safe.
3. Рекомендации и фиксы
Вот конкретные советы + кодовые патчи. Внедряй по приоритету (сначала поломки).
Фикс портов и TCP в resolveHostGo (для поломки 1):
Используй net.Dialer с UDP/TCP fallback.
Patch:Gofunc resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, logf func(string, ...any)) ([]string, int) {
// ... (твой код)
var ips []string
errs := 0
for _, upstream := range dnsList {
server, port := splitDNS(upstream)
if server == "" {
errs++
continue
}
if port == "" {
port = "53"
}
addr := net.JoinHostPort(server, port)
// Сначала UDP
ipsUdp, errUdp := lookupA(host, addr, "udp", 3*time.Second)
if errUdp == nil {
ips = append(ips, ipsUdp...)
continue
} else if logf != nil {
logf("UDP fail for %s@%s: %v", host, addr, errUdp)
}
// Fallback TCP
ipsTcp, errTcp := lookupA(host, addr, "tcp", 5*time.Second)
if errTcp == nil {
ips = append(ips, ipsTcp...)
} else {
errs++
if logf != nil {
logf("TCP fail for %s@%s: %v", host, addr, errTcp)
}
}
}
// dedup
seen := map[string]struct{}{}
unique := []string{}
for _, ip := range ips {
if _, ok := seen[ip]; !ok && !isPrivateIPv4(ip) {
seen[ip] = struct{}{}
unique = append(unique, ip)
}
}
return unique, errs
}
func lookupA(host, addr, network string, timeout time.Duration) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
r := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, network, addr)
},
}
ips, err := r.LookupHost(ctx, host)
if err != nil {
return nil, err
}
var out []string
for _, ip := range ips {
if netip.MustParseAddr(ip).Is4() {
out = append(out, ip)
}
}
return out, nil
}
Увеличь timeout + retry в resolveHostGo (для поломки 2):
Добавь retry для всей функции (3 попытки).
Patch: Оберни loop по upstream в backoff.Retry (как в nft).
Retry для PTR (для поломки 3):
Patch в resolveStaticLabels:Go// Внутри if !names:
for attempt := 0; attempt < 3; attempt++ {
names, err = digPTR(base_ip, dns_for_ptr, 5*time.Second, logf)
if err == nil {
break
}
time.Sleep(500 * time.Millisecond)
}
if err != nil {
ptrErrors++
}
Валидация upstream в loadDNSConfig (для поломки 4):
Patch:Gofunc loadDNSConfig(path string, logf func(string, ...any)) dnsConfig {
// ... (твой код)
valid := func(s string) bool {
if s == "" {
return false
}
_, err := netip.ParseAddrPort(s)
return err == nil
}
cfg.Default = filter(cfg.Default, valid)
cfg.Meta = filter(cfg.Meta, valid)
if len(cfg.Default) == 0 {
cfg.Default = defaultDNS
if logf != nil {
logf("dns-config: no valid default upstreams, fallback to built-in")
}
}
// аналогично для meta
}
func filter(ss []string, fn func(string) bool) []string {
var out []string
for _, s := range ss {
if fn(s) {
out = append(out, s)
}
}
return out
}
Сортировка лейблов в IPMap (для поломки 5):
Patch в runResolverJob после for host, ips...:Gofor ip, labelsMap := range ipMap {
var labels []string
for lbl := range labelsMap {
labels = append(labels, lbl)
}
sort.Strings(labels)
for _, lbl := range labels {
res.IPMap = append(res.IPMap, [2]string{ip, lbl})
}
}
Валидация CIDR в isPrivateIPv4 (для поломки 6):
Patch: Используй netip.ParsePrefix:Gofunc isPrivateIPv4(ip string) bool {
if strings.Contains(ip, "/") {
p, err := netip.ParsePrefix(ip)
if err != nil {
return true // invalid → private (пропустить)
}
return p.IsPrivate()
}
a, err := netip.ParseAddr(ip)
if err != nil {
return true
}
return a.IsPrivate()
}
Rate-limit в concurrent (для слабого 1):
Добавь semaphore в workers-loop:Gosem := make(chan struct{}, 20) // лимит 20 одновременных запросов
for i := 0; i < workers; i++ {
go func() {
for j := range jobs {
sem <- struct{}{}
// resolve
<-sem
}
}()
}
IPv6 поддержка (для слабого 2):
Добавь AAAA в resolveHostGo (параллельно с A).
Добавь isPrivateIPv6.
Кэш invalidation (для слабого 3):
Добавь флаг --force для игнора TTL.
Логи в файл с sync (для слабого 5):
В appendTraceLine добавь file.Sync() после Write.
Итог
Поломки: В основном edge-кейсы (медленный DNS, invalid upstream, большие ответы) — фиксы выше.
Слабые: Масштаб (rate-limit, IPv6) и robustness (retry PTR, validate).
Код в целом очень хороший — внедряй фиксы по приоритету (сначала порты/TCP, timeout/retry).

View File

@@ -928,19 +928,54 @@ RU: Применяет policy-rules и проверяет health. При оши
if p.returncode != 0:
raise RuntimeError(f"systemd-run failed: {p.returncode}\n{out}".strip())
p2 = subprocess.run(
["systemctl", "--user", "show", "-p", "ControlGroup", "--value", unit],
capture_output=True,
text=True,
check=False,
)
out2 = ((p2.stdout or "") + (p2.stderr or "")).strip()
cg = (p2.stdout or "").strip()
if p2.returncode != 0 or not cg:
raise RuntimeError(f"failed to query ControlGroup\n{out2}".strip())
# EN: Some apps (e.g. Chrome wrappers) can return quickly; the transient scope
# EN: may appear/disappear fast. Retry briefly to avoid race.
# RU: Некоторые приложения (например, chrome-wrapper) быстро завершаются; scope
# RU: может появиться/исчезнуть очень быстро. Делаем небольшой retry.
cg = self._control_group_for_unit_retry(unit, timeout_sec=2.0)
return cg, out
def _control_group_for_unit_retry(self, unit: str, *, timeout_sec: float = 2.0) -> str:
u = (unit or "").strip()
if not u:
raise ValueError("empty unit")
deadline = time.time() + max(0.2, float(timeout_sec or 0))
last_out = ""
while time.time() < deadline:
code, out = self._systemctl_user(["show", "-p", "ControlGroup", "--value", u])
last_out = out or ""
if code == 0:
cg = (out or "").strip()
if cg:
return cg
low = (out or "").lower()
if "could not be found" in low or "not found" in low or "loaded units listed" in low:
break
time.sleep(0.1)
# Provide a more actionable error for users.
code_s, out_s = self._systemctl_user(["status", u, "--no-pager", "--plain"])
status_txt = (out_s or "").strip()
hint = (
"EN: Scope unit may have exited immediately. If the app is already running, "
"this launch may not create a new process tree inside the scope.\n"
"EN: Try closing the app полностью and запуск again from here.\n"
"RU: Scope мог завершиться сразу. Если приложение уже запущено, повторный запуск "
"может не создать новый процесс внутри scope.\n"
"RU: Попробуй полностью закрыть приложение и запустить снова отсюда."
)
raise RuntimeError(
(
"failed to query ControlGroup\n"
+ (last_out.strip() or "(no output)")
+ ("\n\nstatus:\n" + status_txt if status_txt else "")
+ "\n\n"
+ hint
).strip()
)
def on_app_run(self) -> None:
def work() -> None:
cmdline = (self.ed_app_cmd.text() or "").strip()