mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2025-10-26 11:27:18 +00:00
Pull request: AGDNS-2965-next-use-signal-handler
Merge in DNS/adguard-home from AGDNS-2965-next-use-signal-handler to master Squashed commit of the following: commitdcf089a18cAuthor: Dimitry Kolyshev <dkolyshev@adguard.com> Date: Fri Jun 20 11:20:45 2025 +0300 next/cmd: fix commitaff109ce97Author: Dimitry Kolyshev <dkolyshev@adguard.com> Date: Thu Jun 19 11:35:33 2025 +0300 all: upd golibs commit17ed979270Author: Dimitry Kolyshev <dkolyshev@adguard.com> Date: Thu Jun 19 08:13:02 2025 +0300 next/cmd: imp code commit6da81fea8cAuthor: Dimitry Kolyshev <dkolyshev@adguard.com> Date: Wed Jun 18 11:49:55 2025 +0300 next/cmd: move pid file logic to serviceMgr commit470a29d837Author: Dimitry Kolyshev <dkolyshev@adguard.com> Date: Wed Jun 18 11:36:47 2025 +0300 next/cmd: imp code, renaming commit6c71890e73Author: Dimitry Kolyshev <dkolyshev@adguard.com> Date: Tue Jun 17 15:28:19 2025 +0300 next/cmd: service manager
This commit is contained in:
parent
16fda88899
commit
b05d0fdb93
4
go.mod
4
go.mod
@ -4,7 +4,7 @@ go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/AdguardTeam/dnsproxy v0.75.6
|
||||
github.com/AdguardTeam/golibs v0.32.11
|
||||
github.com/AdguardTeam/golibs v0.32.14
|
||||
github.com/AdguardTeam/urlfilter v0.20.0
|
||||
github.com/NYTimes/gziphandler v1.1.1
|
||||
github.com/ameshkov/dnscrypt/v2 v2.4.0
|
||||
@ -33,7 +33,7 @@ require (
|
||||
github.com/ti-mo/netfilter v0.5.3
|
||||
go.etcd.io/bbolt v1.4.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476
|
||||
golang.org/x/net v0.41.0
|
||||
golang.org/x/sys v0.33.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
|
||||
8
go.sum
8
go.sum
@ -12,8 +12,8 @@ cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFs
|
||||
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||
github.com/AdguardTeam/dnsproxy v0.75.6 h1:Xz5dciFQeMCtaqBHAyUYGI5PO1Lu7HwIi5ejhSVGsLY=
|
||||
github.com/AdguardTeam/dnsproxy v0.75.6/go.mod h1:WHwb2fHfyujIZLrBymBQtqDUTbzDlNebWqykd1mCNuM=
|
||||
github.com/AdguardTeam/golibs v0.32.11 h1:75EquS8SWvzsM3JFJY0359ZBw66jDjAegteHzh9nSw8=
|
||||
github.com/AdguardTeam/golibs v0.32.11/go.mod h1:LXr0gqqZuVpt+L+bP3Nnr0/CecLmm3rxkdgyyW5JXXM=
|
||||
github.com/AdguardTeam/golibs v0.32.14 h1:b8ExsMuAA7Ewf6bjIbjAtIewWkbEyjDM4v/CU2NxT0Q=
|
||||
github.com/AdguardTeam/golibs v0.32.14/go.mod h1:DUtN+CepuWjA4Z7fxsr14s/qUTkcYmi9TUuxhkBsxOA=
|
||||
github.com/AdguardTeam/urlfilter v0.20.0 h1:X32qiuVCVd8WDYCEsbdZKfXMzwdVqrdulamtUi4rmzs=
|
||||
github.com/AdguardTeam/urlfilter v0.20.0/go.mod h1:gjrywLTxfJh6JOkwi9SU+frhP7kVVEZ5exFGkR99qpk=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
@ -205,8 +205,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA=
|
||||
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/exp/typeparams v0.0.0-20250606033433-dcc06ee1d476 h1:tceuojoK22x7b3gj32PCOPkkFeVtewHm1imqrGyFp1I=
|
||||
golang.org/x/exp/typeparams v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
|
||||
@ -61,38 +61,28 @@ func Main(embeddedFrontend fs.FS) {
|
||||
FileName: opts.confFile,
|
||||
}
|
||||
|
||||
confMgr, err := configmgr.New(startCtx, confMgrConf)
|
||||
svc, err := newServiceMgr(ctx, &serviceMgrConfig{
|
||||
confMgrConf: confMgrConf,
|
||||
logger: baseLogger.With(slogutil.KeyPrefix, "svc"),
|
||||
pidFilePath: opts.pidFile,
|
||||
})
|
||||
errors.Check(err)
|
||||
|
||||
web := confMgr.Web()
|
||||
err = web.Start(startCtx)
|
||||
errors.Check(err)
|
||||
errors.Check(svc.Start(startCtx))
|
||||
|
||||
dns := confMgr.DNS()
|
||||
err = dns.Start(startCtx)
|
||||
errors.Check(err)
|
||||
sigHdlr := service.NewSignalHandler(&service.SignalHandlerConfig{
|
||||
Logger: baseLogger.With(slogutil.KeyPrefix, service.SignalHandlerPrefix),
|
||||
})
|
||||
|
||||
sigHdlr := newSignalHandler(
|
||||
baseLogger.With(slogutil.KeyPrefix, service.SignalHandlerPrefix),
|
||||
confMgrConf,
|
||||
opts.pidFile,
|
||||
web,
|
||||
dns,
|
||||
)
|
||||
sigHdlr.AddService(svc)
|
||||
sigHdlr.AddRefresher(svc)
|
||||
|
||||
os.Exit(sigHdlr.handle(ctx))
|
||||
os.Exit(sigHdlr.Handle(ctx))
|
||||
}
|
||||
|
||||
// Default timeouts.
|
||||
//
|
||||
// TODO(a.garipov): Make configurable.
|
||||
const (
|
||||
defaultTimeoutStart = 1 * time.Minute
|
||||
defaultTimeoutShutdown = 5 * time.Second
|
||||
defaultTimeoutStart = 1 * time.Minute
|
||||
)
|
||||
|
||||
// newConfigMgr returns a new configuration manager using defaultTimeout as the
|
||||
// context timeout.
|
||||
func newConfigMgr(ctx context.Context, c *configmgr.Config) (m *configmgr.Manager, err error) {
|
||||
return configmgr.New(ctx, c)
|
||||
}
|
||||
|
||||
187
internal/next/cmd/service.go
Normal file
187
internal/next/cmd/service.go
Normal file
@ -0,0 +1,187 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/AdguardTeam/AdGuardHome/internal/next/configmgr"
|
||||
"github.com/AdguardTeam/golibs/errors"
|
||||
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
||||
"github.com/AdguardTeam/golibs/service"
|
||||
"github.com/google/renameio/v2/maybe"
|
||||
)
|
||||
|
||||
// serviceMgr manages AdGuard Home services.
|
||||
type serviceMgr struct {
|
||||
// confMgrMu protects confMgr.
|
||||
confMgrMu *sync.RWMutex
|
||||
|
||||
confMgr *configmgr.Manager
|
||||
confMgrConf *configmgr.Config
|
||||
logger *slog.Logger
|
||||
pidFilePath string
|
||||
}
|
||||
|
||||
// serviceMgrConfig contains service manager configuration parameters.
|
||||
type serviceMgrConfig struct {
|
||||
// confMgrConf is the configuration manager config, it must not be nil.
|
||||
confMgrConf *configmgr.Config
|
||||
|
||||
// logger is the logger used to log services activity, it must not be nil.
|
||||
logger *slog.Logger
|
||||
|
||||
// pidFilePath is the path to the file where to store the PID, if any.
|
||||
pidFilePath string
|
||||
}
|
||||
|
||||
// newServiceMgr creates a new *serviceMgr.
|
||||
func newServiceMgr(ctx context.Context, conf *serviceMgrConfig) (s *serviceMgr, err error) {
|
||||
confMgr, err := configmgr.New(ctx, conf.confMgrConf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating config manager: %w", err)
|
||||
}
|
||||
|
||||
return &serviceMgr{
|
||||
confMgr: confMgr,
|
||||
confMgrMu: &sync.RWMutex{},
|
||||
confMgrConf: conf.confMgrConf,
|
||||
logger: conf.logger,
|
||||
pidFilePath: conf.pidFilePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// type check
|
||||
var _ service.Interface = (*serviceMgr)(nil)
|
||||
|
||||
// Start implements the [service.Interface] interface for *serviceMgr.
|
||||
func (s *serviceMgr) Start(ctx context.Context) (err error) {
|
||||
s.writePID(ctx)
|
||||
|
||||
s.confMgrMu.RLock()
|
||||
defer s.confMgrMu.RUnlock()
|
||||
|
||||
var errs []error
|
||||
|
||||
err = s.confMgr.Web().Start(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("starting web: %w", err))
|
||||
}
|
||||
|
||||
err = s.confMgr.DNS().Start(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("starting dnssvc: %w", err))
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// writePID writes the PID to the file. Any errors are reported to log.
|
||||
func (s *serviceMgr) writePID(ctx context.Context) {
|
||||
if s.pidFilePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
pid := os.Getpid()
|
||||
data := strconv.AppendInt(nil, int64(pid), 10)
|
||||
data = append(data, '\n')
|
||||
|
||||
err := maybe.WriteFile(s.pidFilePath, data, 0o644)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "writing pidfile", slogutil.KeyError, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.DebugContext(ctx, "wrote pid", "file", s.pidFilePath, "pid", pid)
|
||||
}
|
||||
|
||||
// Shutdown implements the [service.Interface] interface for *serviceMgr.
|
||||
func (s *serviceMgr) Shutdown(ctx context.Context) (err error) {
|
||||
s.confMgrMu.RLock()
|
||||
defer s.confMgrMu.RUnlock()
|
||||
|
||||
var errs []error
|
||||
|
||||
err = s.confMgr.Web().Shutdown(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("shutting down web: %w", err))
|
||||
}
|
||||
|
||||
err = s.confMgr.DNS().Shutdown(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("shutting down dnssvc: %w", err))
|
||||
}
|
||||
|
||||
s.removePID(ctx)
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// removePID removes the PID file. Any errors are reported to log.
|
||||
func (s *serviceMgr) removePID(ctx context.Context) {
|
||||
if s.pidFilePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
err := os.Remove(s.pidFilePath)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "removing pidfile", slogutil.KeyError, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.DebugContext(ctx, "removed pidfile", "file", s.pidFilePath)
|
||||
}
|
||||
|
||||
// type check
|
||||
var _ service.Refresher = (*serviceMgr)(nil)
|
||||
|
||||
// Refresh implements the [service.Refresher] interface for *serviceMgr.
|
||||
func (s *serviceMgr) Refresh(ctx context.Context) (err error) {
|
||||
s.logger.InfoContext(ctx, "reconfiguring started")
|
||||
|
||||
err = s.Shutdown(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shutdown failed: %w", err)
|
||||
}
|
||||
|
||||
// TODO(a.garipov): This is a very rough way to do it. Some services can
|
||||
// be reconfigured without the full shutdown, and the error handling is
|
||||
// currently not the best.
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeoutStart)
|
||||
defer cancel()
|
||||
|
||||
err = s.updConfMgr(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating configuration manager: %w", err)
|
||||
}
|
||||
|
||||
err = s.Start(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restarting services: %w", err)
|
||||
}
|
||||
|
||||
s.logger.InfoContext(ctx, "reconfiguring finished")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updConfMgr updates the configuration manager.
|
||||
func (s *serviceMgr) updConfMgr(ctx context.Context) (err error) {
|
||||
confMgr, err := configmgr.New(ctx, s.confMgrConf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating config manager: %w", err)
|
||||
}
|
||||
|
||||
s.confMgrMu.Lock()
|
||||
defer s.confMgrMu.Unlock()
|
||||
|
||||
s.confMgr = confMgr
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -1,203 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AdguardTeam/AdGuardHome/internal/next/configmgr"
|
||||
"github.com/AdguardTeam/golibs/errors"
|
||||
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
||||
"github.com/AdguardTeam/golibs/osutil"
|
||||
"github.com/AdguardTeam/golibs/service"
|
||||
"github.com/google/renameio/v2/maybe"
|
||||
)
|
||||
|
||||
// signalHandler processes incoming signals and shuts services down.
|
||||
type signalHandler struct {
|
||||
// logger is used for logging the operation of the signal handler.
|
||||
logger *slog.Logger
|
||||
|
||||
// confMgrConf contains the configuration parameters for the configuration
|
||||
// manager.
|
||||
confMgrConf *configmgr.Config
|
||||
|
||||
// signal is the channel to which OS signals are sent.
|
||||
signal chan os.Signal
|
||||
|
||||
// pidFile is the path to the file where to store the PID, if any.
|
||||
pidFile string
|
||||
|
||||
// services are the services that are shut down before application exiting.
|
||||
services []service.Interface
|
||||
|
||||
// shutdownTimeout is the timeout for the shutdown operation.
|
||||
shutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
// handle processes OS signals. It blocks until a termination or a
|
||||
// reconfiguration signal is received, after which it either shuts down all
|
||||
// services or reconfigures them. ctx is used for logging and serves as the
|
||||
// base for the shutdown timeout. status is [osutil.ExitCodeSuccess] on success
|
||||
// and [osutil.ExitCodeFailure] on error.
|
||||
//
|
||||
// TODO(a.garipov): Add reconfiguration logic to golibs.
|
||||
func (h *signalHandler) handle(ctx context.Context) (status osutil.ExitCode) {
|
||||
defer slogutil.RecoverAndLog(ctx, h.logger)
|
||||
|
||||
h.writePID(ctx)
|
||||
|
||||
for sig := range h.signal {
|
||||
h.logger.InfoContext(ctx, "received", "signal", sig)
|
||||
|
||||
if osutil.IsReconfigureSignal(sig) {
|
||||
err := h.reconfigure(ctx)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "reconfiguration error", slogutil.KeyError, err)
|
||||
|
||||
return osutil.ExitCodeFailure
|
||||
}
|
||||
} else if osutil.IsShutdownSignal(sig) {
|
||||
status = h.shutdown(ctx)
|
||||
|
||||
h.removePID(ctx)
|
||||
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
// Shouldn't happen, since h.signal is currently never closed.
|
||||
panic("unexpected close of h.signal")
|
||||
}
|
||||
|
||||
// writePID writes the PID to the file, if needed. Any errors are reported to
|
||||
// log.
|
||||
func (h *signalHandler) writePID(ctx context.Context) {
|
||||
if h.pidFile == "" {
|
||||
return
|
||||
}
|
||||
|
||||
pid := os.Getpid()
|
||||
data := strconv.AppendInt(nil, int64(pid), 10)
|
||||
data = append(data, '\n')
|
||||
|
||||
err := maybe.WriteFile(h.pidFile, data, 0o644)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "writing pidfile", slogutil.KeyError, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.DebugContext(ctx, "wrote pid", "file", h.pidFile, "pid", pid)
|
||||
}
|
||||
|
||||
// reconfigure rereads the configuration file and updates and restarts services.
|
||||
func (h *signalHandler) reconfigure(ctx context.Context) (err error) {
|
||||
h.logger.InfoContext(ctx, "reconfiguring started")
|
||||
|
||||
status := h.shutdown(ctx)
|
||||
if status != osutil.ExitCodeSuccess {
|
||||
return errors.Error("shutdown failed")
|
||||
}
|
||||
|
||||
// TODO(a.garipov): This is a very rough way to do it. Some services can
|
||||
// be reconfigured without the full shutdown, and the error handling is
|
||||
// currently not the best.
|
||||
|
||||
var errs []error
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeoutStart)
|
||||
defer cancel()
|
||||
|
||||
confMgr, err := newConfigMgr(ctx, h.confMgrConf)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("configuration manager: %w", err))
|
||||
}
|
||||
|
||||
web := confMgr.Web()
|
||||
err = web.Start(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("starting web: %w", err))
|
||||
}
|
||||
|
||||
dns := confMgr.DNS()
|
||||
err = dns.Start(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("starting dns: %w", err))
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
h.services = []service.Interface{
|
||||
dns,
|
||||
web,
|
||||
}
|
||||
|
||||
h.logger.InfoContext(ctx, "reconfiguring finished")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdown gracefully shuts down all services.
|
||||
func (h *signalHandler) shutdown(ctx context.Context) (status int) {
|
||||
ctx, cancel := context.WithTimeout(ctx, h.shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
status = osutil.ExitCodeSuccess
|
||||
|
||||
h.logger.InfoContext(ctx, "shutting down")
|
||||
for i, svc := range h.services {
|
||||
err := svc.Shutdown(ctx)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "shutting down service", "idx", i, slogutil.KeyError, err)
|
||||
status = osutil.ExitCodeFailure
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// newSignalHandler returns a new signalHandler that shuts down svcs. logger
|
||||
// and confMgrConf must not be nil.
|
||||
func newSignalHandler(
|
||||
logger *slog.Logger,
|
||||
confMgrConf *configmgr.Config,
|
||||
pidFile string,
|
||||
svcs ...service.Interface,
|
||||
) (h *signalHandler) {
|
||||
h = &signalHandler{
|
||||
logger: logger,
|
||||
confMgrConf: confMgrConf,
|
||||
signal: make(chan os.Signal, 1),
|
||||
pidFile: pidFile,
|
||||
services: svcs,
|
||||
shutdownTimeout: defaultTimeoutShutdown,
|
||||
}
|
||||
|
||||
notifier := osutil.DefaultSignalNotifier{}
|
||||
osutil.NotifyShutdownSignal(notifier, h.signal)
|
||||
osutil.NotifyReconfigureSignal(notifier, h.signal)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// removePID removes the PID file, if any.
|
||||
func (h *signalHandler) removePID(ctx context.Context) {
|
||||
if h.pidFile == "" {
|
||||
return
|
||||
}
|
||||
|
||||
err := os.Remove(h.pidFile)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "removing pidfile", slogutil.KeyError, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.DebugContext(ctx, "removed pidfile", "file", h.pidFile)
|
||||
}
|
||||
@ -25,6 +25,8 @@ import (
|
||||
)
|
||||
|
||||
// ConfigManager is the configuration manager interface.
|
||||
//
|
||||
// TODO(a.garipov): Add docs.
|
||||
type ConfigManager interface {
|
||||
DNS() (svc agh.ServiceWithConfig[*dnssvc.Config])
|
||||
Web() (svc agh.ServiceWithConfig[*Config])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user