Pull request 2551: AGDNS-2966-add-manager-extension

Squashed commit of the following:

commit 902b8a52b9a89d5b568ab09c786179df56efdcd3
Author: Igor 🐧 Lobanov <i.lobanov@adguard.com>
Date:   Mon Dec 22 14:46:35 2025 +0300

    Pull request 2553: Fixed e2e tests
    
    Fixed e2e tests
    Changed nslookup to dig
    
    Squashed commit of the following:
    
    commit c0dcce547b34743b07e82e16184a5509bb6c98cf
    Author: Igor Lobanov <i.lobanov@adguard.com>
    Date:   Sat Dec 20 13:34:50 2025 +0100
    
        fix e2e tests
    
    commit 6ae1d02aa241255838c0c2fabd0394061f7c69ba
    Author: Igor Lobanov <i.lobanov@adguard.com>
    Date:   Sat Dec 20 13:06:51 2025 +0100
    
        fixed e2e tests
        changed nslookup to dig

commit e67ed4ccb884d025c874b8bbcb97f1c3f2ca49c9
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Fri Dec 19 18:57:39 2025 +0300

    ossvc: imp docs

commit 926719b6fbfb8ea3fd5daf51da444deda6f50b61
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Dec 18 19:53:55 2025 +0300

    ossvc: imp code

commit d0d7abf92a05317aca2fa3b9ad724c0b5fbb9b03
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Dec 17 18:46:57 2025 +0300

    ossvc: add ext iface, imp code
This commit is contained in:
Eugene Burkov 2025-12-22 14:58:58 +03:00
parent 81213e81cf
commit ddc5b1343a
12 changed files with 267 additions and 89 deletions

2
client/package.json vendored
View file

@ -12,7 +12,7 @@
"test": "vitest --run",
"test:watch": "vitest --watch",
"test:e2e": "npx playwright test tests/e2e",
"test:e2e:interactive": "npx playwright test --ui",
"test:e2e:interactive": "npx playwright test --headed",
"test:e2e:debug": "npx playwright test --debug",
"test:e2e:codegen": "npx playwright codegen",
"typecheck": "tsc --noEmit",

View file

@ -20,7 +20,7 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
reporter: [['html', { open: 'never' }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
@ -40,13 +40,13 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
stdout: process.env.CI ? 'pipe' : 'ignore',
command: `${!process.env.CI ? 'sudo ' : ''}./AdGuardHome --local-frontend -v -c ${CONFIG_FILE_PATH}`,
url: 'http://127.0.0.1:3000',
cwd: '..',
reuseExistingServer: !process.env.CI,
timeout: 10000,
},
webServer: process.env.CI
? {
stdout: 'pipe',
command: `./AdGuardHome --local-frontend -v -c ${CONFIG_FILE_PATH}`,
url: 'http://127.0.0.1:3000',
cwd: '..',
timeout: 10000,
}
: undefined,
});

View file

@ -27,12 +27,12 @@ test.describe('General Settings', () => {
await expect(browsingSecurity).toBeChecked();
}
const resultEnabled = execSync('nslookup totalvirus.com 127.0.0.1').toString();
const resultEnabled = execSync('dig @127.0.0.1 totalvirus.com').toString();
await browsingSecurityLabel.click();
await expect(browsingSecurity).not.toBeChecked();
const resultDisabled = execSync('nslookup totalvirus.com 127.0.0.1').toString();
const resultDisabled = execSync('dig @127.0.0.1 totalvirus.com').toString();
expect(resultEnabled).not.toEqual(resultDisabled);
@ -55,12 +55,12 @@ test.describe('General Settings', () => {
await expect(parentalControl).toBeChecked();
}
const resultEnabled = execSync('nslookup pornhub.com 127.0.0.1').toString();
const resultEnabled = execSync('dig @127.0.0.1 pornhub.com').toString();
await parentalControlLabel.click();
await expect(parentalControl).not.toBeChecked();
const resultDisabled = execSync('nslookup pornhub.com 127.0.0.1').toString();
const resultDisabled = execSync('dig @127.0.0.1 pornhub.com').toString();
expect(resultEnabled).not.toEqual(resultDisabled);

View file

@ -1,31 +1,56 @@
import { chromium, type FullConfig } from '@playwright/test';
import { ADMIN_USERNAME, ADMIN_PASSWORD, PORT } from '../constants';
import { ADMIN_USERNAME, ADMIN_PASSWORD, PORT, CONFIG_FILE_PATH } from '../constants';
const BASE_URL = `http://127.0.0.1:${PORT}`;
async function checkServerAvailable(): Promise<boolean> {
try {
const response = await fetch(BASE_URL);
return response.ok || response.status === 302;
} catch {
return false;
}
}
async function globalSetup(config: FullConfig) {
if (!process.env.CI) {
const isServerRunning = await checkServerAvailable();
if (!isServerRunning) {
console.error(
`\nAdGuard Home server is not running. Start it first:\n sudo ./AdGuardHome --local-frontend -v -c ${CONFIG_FILE_PATH}\n`,
);
process.exit(1);
}
}
const browser = await chromium.launch({
slowMo: 100,
});
const page = await browser.newPage({ baseURL: config.webServer?.url });
const page = await browser.newPage({ baseURL: config.webServer?.url || BASE_URL });
try {
await page.goto('/');
await page.goto('/');
// Check if we're on the install page or already installed
const isInstallPage = page.url().includes('/install.html');
if (isInstallPage) {
await page.getByTestId('install_get_started').click();
await page.getByTestId('install_web_port').fill(PORT.toString());
await page.getByTestId('install_next').click();
await page.getByTestId('install_username').fill(ADMIN_USERNAME);
await page.getByTestId('install_username').blur();
await page.getByTestId('install_password').fill(ADMIN_PASSWORD);
await page.getByTestId('install_confirm_password').click();
await page.getByTestId('install_password').blur();
await page.getByTestId('install_confirm_password').fill(ADMIN_PASSWORD);
await page.getByTestId('install_confirm_password').blur();
await page.getByTestId('install_next').click();
await page.getByTestId('install_next').click();
await page.getByTestId('install_open_dashboard').click();
await page.waitForURL((url) => !url.href.endsWith('/install.html'));
} catch (error) {
console.error('Error during global setup:', error);
} finally {
await browser.close();
}
await browser.close();
}
export default globalSetup;

View file

@ -24,18 +24,37 @@ test.describe('Rewrites', () => {
await page.getByTestId('rewrites_answer').fill(EXAMPLE_ANSWER);
await page.getByTestId('rewrites_save').click();
await expect(page.locator('.logs__text').filter({ hasText: EXAMPLE_DOMAIN })).toBeVisible();
await expect(page.locator('.logs__text').filter({ hasText: EXAMPLE_ANSWER })).toBeVisible();
await expect(page.locator('.logs__text').filter({ hasText: EXAMPLE_DOMAIN }).first()).toBeVisible();
await expect(page.locator('.logs__text').filter({ hasText: EXAMPLE_ANSWER }).first()).toBeVisible();
});
test('should edit a DNS rewrite', async ({ page }) => {
await page.getByTestId('edit-rewrite').first().click();
await expect(page.getByTestId('rewrites_domain')).toHaveValue(EXAMPLE_DOMAIN);
// Use the first existing rewrite instead of creating a new one
// Wait for the table to load
await expect(page.getByTestId('edit-rewrite').first()).toBeVisible({ timeout: 10000 });
await page.getByTestId('rewrites_domain').clear();
await page.getByTestId('rewrites_domain').fill(EXAMPLE_UPDATED_DOMAIN);
// Get the current domain value before editing
await page.getByTestId('edit-rewrite').first().click();
const originalDomain = await page.getByTestId('rewrites_domain').inputValue();
// Edit the domain - use keyboard to ensure isDirty is triggered
const domainInput = page.getByTestId('rewrites_domain');
await domainInput.click();
await domainInput.press('Control+a');
await domainInput.pressSequentially(EXAMPLE_UPDATED_DOMAIN);
await domainInput.blur();
await page.getByTestId('rewrites_save').click();
await expect(page.locator('.logs__text').filter({ hasText: EXAMPLE_UPDATED_DOMAIN })).toBeVisible();
// Verify the update
await expect(page.locator('.logs__text').filter({ hasText: EXAMPLE_UPDATED_DOMAIN }).first()).toBeVisible({ timeout: 10000 });
// Restore original value
await page.getByTestId('edit-rewrite').first().click();
const restoreInput = page.getByTestId('rewrites_domain');
await restoreInput.click();
await restoreInput.press('Control+a');
await restoreInput.pressSequentially(originalDomain);
await restoreInput.blur();
await page.getByTestId('rewrites_save').click();
});
});

View file

@ -3,7 +3,7 @@ package ossvc
// ActionName is the type for actions' names. It has the following valid
// values:
// - [ActionNameInstall]
// - [ActionNameReload]
// - [ActionNameRestart]
// - [ActionNameStart]
// - [ActionNameStop]
// - [ActionNameUninstall]
@ -11,7 +11,7 @@ type ActionName string
const (
ActionNameInstall ActionName = "install"
ActionNameReload ActionName = "reload"
ActionNameRestart ActionName = "restart"
ActionNameStart ActionName = "start"
ActionNameStop ActionName = "stop"
ActionNameUninstall ActionName = "uninstall"

View file

@ -7,11 +7,11 @@ import (
"github.com/kardianos/service"
)
// configureServiceOptions defines additional settings of the service
// ConfigureServiceOptions defines additional settings of the service
// configuration. conf must not be nil.
//
//lint:ignore U1000 TODO(e.burkov): Use.
func configureServiceOptions(conf *service.Config, versionInfo string) {
// TODO(e.burkov): Use [timeutil.Clock].
func ConfigureServiceOptions(conf *service.Config, versionInfo string) {
conf.Option["SvcInfo"] = fmt.Sprintf("%s %s", versionInfo, time.Now())
configureOSOptions(conf)

View file

@ -19,8 +19,8 @@ func (a *ActionInstall) Name() (name ActionName) { return ActionNameInstall }
// isAction implements the [Action] interface for *ActionInstall.
func (a *ActionInstall) isAction() {}
// ActionReload is the implementation of the [Action] interface.
type ActionReload struct {
// ActionRestart is the implementation of the [Action] interface.
type ActionRestart struct {
// ServiceConf is the configuration for the service to control.
//
// TODO(e.burkov): Get rid of github.com/kardianos/service dependency and
@ -28,11 +28,11 @@ type ActionReload struct {
ServiceConf *service.Config
}
// Name implements the [Action] interface for *ActionReload.
func (a *ActionReload) Name() (name ActionName) { return ActionNameReload }
// Name implements the [Action] interface for *ActionRestart.
func (a *ActionRestart) Name() (name ActionName) { return ActionNameRestart }
// isAction implements the [Action] interface for *ActionReload.
func (a *ActionReload) isAction() {}
// isAction implements the [Action] interface for *ActionRestart.
func (a *ActionRestart) isAction() {}
// ActionStart is the implementation of the [Action] interface.
type ActionStart struct {

View file

@ -1,12 +1,15 @@
package ossvc
import (
"bytes"
"context"
_ "embed"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"strconv"
"syscall"
"github.com/AdguardTeam/AdGuardHome/internal/aghos"
"github.com/AdguardTeam/golibs/errors"
@ -16,13 +19,15 @@ import (
// TODO(e.burkov): Declare managers for each OS.
// manager is the implementation of [Manager] that wraps [service.Service].
// manager is the implementation of [Manager] that uses [service.Service].
type manager struct {
logger *slog.Logger
cmdCons executil.CommandConstructor
logger *slog.Logger
cmdCons executil.CommandConstructor
isOpenWrt bool
isUnixSystemV bool
}
// newManager creates a new [Manager] that wraps [service.Service].
// newManager creates a new [Manager] that uses [service.Service].
//
// TODO(e.burkov): Return error.
func newManager(_ context.Context, conf *ManagerConfig) (mgr *manager) {
@ -31,8 +36,10 @@ func newManager(_ context.Context, conf *ManagerConfig) (mgr *manager) {
chooseSystem()
return &manager{
logger: conf.Logger,
cmdCons: conf.CommandConstructor,
logger: conf.Logger,
cmdCons: conf.CommandConstructor,
isOpenWrt: aghos.IsOpenWrt(),
isUnixSystemV: service.Platform() == "unix-systemv",
}
}
@ -43,20 +50,32 @@ var _ Manager = (*manager)(nil)
func (m *manager) Perform(ctx context.Context, action Action) (err error) {
switch action := action.(type) {
case *ActionInstall:
return m.install(ctx, action)
case *ActionReload:
return m.reload(ctx, action)
err = m.install(ctx, action)
case *ActionRestart:
err = m.restart(ctx, action)
case *ActionStart:
return m.start(ctx, action)
err = m.start(ctx, action)
case *ActionStop:
return m.stop(ctx, action)
err = m.stop(ctx, action)
case *ActionUninstall:
return m.uninstall(ctx, action)
err = m.uninstall(ctx, action)
default:
return fmt.Errorf("action: %w: %T(%[2]v)", errors.ErrBadEnumValue, action)
err = fmt.Errorf("action: %w: %T(%[2]v)", errors.ErrBadEnumValue, action)
}
if err != nil {
// Don't wrap the error, since it's informative enough as is.
return err
}
m.logger.DebugContext(ctx, "performed service action", "action", action.Name())
return nil
}
// statusRestartOnFail is a custom status value used to indicate the service's
// state of restarting after failed start.
const statusRestartOnFail = service.StatusStopped + 1
// Status implements the [Manager] interface for *manager.
func (m *manager) Status(ctx context.Context, name ServiceName) (status Status, err error) {
m.logger.InfoContext(ctx, "getting service status", "name", name)
@ -69,10 +88,15 @@ func (m *manager) Status(ctx context.Context, name ServiceName) (status Status,
}
svcStatus, err := s.Status()
if err != nil && service.Platform() == "unix-systemv" {
if err != nil && m.isUnixSystemV {
var code int
code, err = m.runInitdCommand(ctx, string(name), "status")
if err != nil || code != 0 {
// Treat an error or non-zero exit code as stopped status on Unix
// System V.
//
// TODO(e.burkov): Investigate if it's a valid assumption, and
// properly handle errors in similar cases.
return StatusStopped, nil
}
@ -87,30 +111,75 @@ func (m *manager) Status(ctx context.Context, name ServiceName) (status Status,
return "", fmt.Errorf("getting service status: %w", err)
}
switch svcStatus {
case service.StatusRunning:
return StatusRunning, nil
case service.StatusStopped:
return StatusStopped, nil
default:
return "", fmt.Errorf("service status: %w: %v", errors.ErrBadEnumValue, svcStatus)
return statusToInternal(svcStatus)
}
// type check
var _ ReloadManager = (*manager)(nil)
// Reload implements the [ReloadManager] interface for *manager.
//
// TODO(e.burkov): On Windows just don't implement this interface.
func (m *manager) Reload(ctx context.Context, name ServiceName) (err error) {
if runtime.GOOS == "windows" {
return errors.ErrUnsupported
}
nameStr := string(name)
var pid int
pidFile := filepath.Join("/var", "run", nameStr+".pid")
data, err := os.ReadFile(pidFile)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("reading service pid file: %w", err)
}
pid, err = aghos.PIDByCommand(ctx, m.logger, nameStr, os.Getpid())
if err != nil {
return fmt.Errorf("finding process: %w", err)
}
} else {
parts := bytes.SplitN(data, []byte("\n"), 2)
if len(parts) == 0 {
return fmt.Errorf("parsing %q: %w", pidFile, errors.ErrEmptyValue)
}
pidStr := string(bytes.TrimSpace(parts[0]))
pid, err = strconv.Atoi(pidStr)
if err != nil {
return fmt.Errorf("parsing pid from %q: %w", pidFile, err)
}
}
proc, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("finding process with pid %d: %w", pid, err)
}
err = proc.Signal(syscall.SIGHUP)
if err != nil {
return fmt.Errorf("sending sighup to process with pid %d: %w", pid, err)
}
return nil
}
// install installs the service in the service manager.
func (m *manager) install(ctx context.Context, action *ActionInstall) (err error) {
m.logger.InfoContext(ctx, "installing service", "name", action.ServiceConf.Name)
s, err := service.New(nil, action.ServiceConf)
s, err := service.New(emptyInterface{}, action.ServiceConf)
if err != nil {
return fmt.Errorf("creating service: %w", err)
}
if err = s.Install(); err != nil {
err = s.Install()
if err != nil {
return fmt.Errorf("installing service: %w", err)
}
if aghos.IsOpenWrt() {
if m.isOpenWrt {
// On OpenWrt it is important to run enable after the service
// installation. Otherwise, the service won't start on the system
// startup.
@ -123,19 +192,22 @@ func (m *manager) install(ctx context.Context, action *ActionInstall) (err error
return nil
}
// reload stops, if not yet, and starts the configured service in the service
// restart stops, if not yet, and starts the configured service in the service
// manager.
func (m *manager) reload(ctx context.Context, action *ActionReload) (err error) {
m.logger.InfoContext(ctx, "reloading service", "name", action.ServiceConf.Name)
func (m *manager) restart(ctx context.Context, action *ActionRestart) (err error) {
m.logger.InfoContext(ctx, "restarting service", "name", action.ServiceConf.Name)
s, err := service.New(nil, action.ServiceConf)
s, err := service.New(emptyInterface{}, action.ServiceConf)
if err != nil {
return fmt.Errorf("creating service: %w", err)
}
err = s.Restart()
if err != nil && service.Platform() == "unix-systemv" {
_, err = m.runInitdCommand(ctx, action.ServiceConf.Name, "restart")
if err != nil && m.isUnixSystemV {
_, initdErr := m.runInitdCommand(ctx, action.ServiceConf.Name, "restart")
if initdErr != nil {
return fmt.Errorf("%w (restarting via init.d: %w)", err, initdErr)
}
}
return err
@ -150,14 +222,17 @@ func (m *manager) start(ctx context.Context, action *ActionStart) (err error) {
m.logger.ErrorContext(ctx, "pre-check failed", "err", err)
}
s, err := service.New(nil, action.ServiceConf)
s, err := service.New(emptyInterface{}, action.ServiceConf)
if err != nil {
return fmt.Errorf("creating service: %w", err)
}
err = s.Start()
if err != nil && service.Platform() == "unix-systemv" {
_, err = m.runInitdCommand(ctx, action.ServiceConf.Name, "start")
if err != nil && m.isUnixSystemV {
_, initdErr := m.runInitdCommand(ctx, action.ServiceConf.Name, "start")
if initdErr != nil {
return fmt.Errorf("%w (starting via init.d: %w)", err, initdErr)
}
}
return err
@ -167,14 +242,17 @@ func (m *manager) start(ctx context.Context, action *ActionStart) (err error) {
func (m *manager) stop(ctx context.Context, action *ActionStop) (err error) {
m.logger.InfoContext(ctx, "stopping service", "name", action.ServiceConf.Name)
s, err := service.New(nil, action.ServiceConf)
s, err := service.New(emptyInterface{}, action.ServiceConf)
if err != nil {
return fmt.Errorf("creating service: %w", err)
}
err = s.Stop()
if err != nil && service.Platform() == "unix-systemv" {
_, err = m.runInitdCommand(ctx, action.ServiceConf.Name, "stop")
if err != nil && m.isUnixSystemV {
_, initdErr := m.runInitdCommand(ctx, action.ServiceConf.Name, "stop")
if initdErr != nil {
return fmt.Errorf("%w (stopping via init.d: %w)", err, initdErr)
}
}
return err
@ -184,7 +262,7 @@ func (m *manager) stop(ctx context.Context, action *ActionStop) (err error) {
func (m *manager) uninstall(ctx context.Context, action *ActionUninstall) (err error) {
m.logger.InfoContext(ctx, "uninstalling service", "name", action.ServiceConf.Name)
if aghos.IsOpenWrt() {
if m.isOpenWrt {
// On OpenWrt it is important to run disable command first as it will
// remove the symlink.
_, err = m.runInitdCommand(ctx, action.ServiceConf.Name, "disable")
@ -193,20 +271,24 @@ func (m *manager) uninstall(ctx context.Context, action *ActionUninstall) (err e
}
}
s, err := service.New(nil, action.ServiceConf)
s, err := service.New(emptyInterface{}, action.ServiceConf)
if err != nil {
return fmt.Errorf("creating service: %w", err)
}
if err = s.Stop(); err != nil {
err = s.Stop()
if err != nil {
m.logger.DebugContext(ctx, "stopping service", "err", err)
}
if err = s.Uninstall(); err != nil {
err = s.Uninstall()
if err != nil {
return fmt.Errorf("uninstalling service: %w", err)
}
removeLaunchdStdLogs(ctx, m.logger)
if runtime.GOOS == "darwin" {
removeLaunchdStdLogs(ctx, m.logger)
}
return nil
}
@ -248,9 +330,24 @@ func (m *manager) runInitdCommand(
serviceName string,
action string,
) (code int, err error) {
confPath := "/etc/init.d/" + serviceName
confPath := filepath.Join("/etc", "init.d", serviceName)
// Pass the script and action as a single string argument.
//
// TODO(e.burkov): Use CommandConstructor.
code, _, err = aghos.RunCommand(ctx, m.cmdCons, "sh", "-c", confPath, action)
return code, err
}
// emptyInterface is an empty implementation of the [service.Interface], as the
// actual implementation is onlyy needed for the [service.Service.Run] method.
type emptyInterface struct{}
// type check
var _ service.Interface = emptyInterface{}
// Start implements the [service.Interface] interface for emptyInterface.
func (emptyInterface) Start(_ service.Service) (err error) { return nil }
// Stop implements the [service.Interface] interface for emptyInterface.
func (emptyInterface) Stop(_ service.Service) (err error) { return nil }

View file

@ -4,6 +4,13 @@
// TODO(e.burkov): Add tests.
package ossvc
import (
"fmt"
"github.com/AdguardTeam/golibs/errors"
"github.com/kardianos/service"
)
// ServiceName is the name of a service.
//
// TODO(e.burkov): Validate for each platform.
@ -21,4 +28,25 @@ const (
// StatusRunning means that the service is running.
StatusRunning Status = "running"
// StatusRestartOnFail means that the service is restarting after failed
// start.
StatusRestartOnFail Status = "restart on fail"
)
// statusToInternal converts a service.Status to a Status.
//
// TODO(e.burkov): Get rid of [service] package dependency and remove this
// function.
func statusToInternal(status service.Status) (s Status, err error) {
switch status {
case service.StatusRunning:
return StatusRunning, nil
case service.StatusStopped:
return StatusStopped, nil
case statusRestartOnFail:
return StatusRestartOnFail, nil
default:
return "", fmt.Errorf("service status: %w: %v", errors.ErrBadEnumValue, status)
}
}

View file

@ -0,0 +1,13 @@
package ossvc
import "context"
// ReloadManager is the extension interface for [Manager] that provides an
// ability to reload a service.
type ReloadManager interface {
Manager
// Reload reloads the service with the given name. As opposed to
// [ActionRestart], this method does not stop the service.
Reload(ctx context.Context, name ServiceName) (err error)
}

View file

@ -235,10 +235,6 @@ func parseSystemctlShow(output io.Reader) (status service.Status, err error) {
return statusFromState(loadState, activeState, subState)
}
// statusRestartOnFail is a custom status value used to indicate the service's
// state of restarting after failed start.
const statusRestartOnFail = service.StatusStopped + 1
// statusFromState returns the service status based on the systemctl state
// property values.
func statusFromState(loadState, activeState, subState string) (status service.Status, err error) {