ui: snap app picker + runtime scopes list/cleanup

This commit is contained in:
beckline
2026-02-15 01:57:28 +03:00
parent f6a7cfa85a
commit f74b1cf9a9
5 changed files with 312 additions and 4 deletions

View File

@@ -0,0 +1,460 @@
Окей, давай сначала аккуратно разрулим логику, а уже потом можно будет добивать конкретный код (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, когда на самом деле всё ок.