package client import ( "crypto/tls" "errors" "fmt" "net/http" "net/url" "os" "path" "strconv" "strings" "github.com/bakito/adguardhome-sync/pkg/client/model" "github.com/bakito/adguardhome-sync/pkg/log" "github.com/bakito/adguardhome-sync/pkg/types" "github.com/bakito/adguardhome-sync/pkg/utils" "github.com/go-resty/resty/v2" "go.uber.org/zap" ) const envRedirectPolicyNoOfRedirects = "REDIRECT_POLICY_NO_OF_REDIRECTS" var ( l = log.GetLogger("client") // ErrSetupNeeded custom error ErrSetupNeeded = errors.New("setup needed") ) func detailedError(resp *resty.Response, err error) error { e := resp.Status() if len(resp.Body()) > 0 { e += fmt.Sprintf("(%s)", string(resp.Body())) } if err != nil { e += fmt.Sprintf(": %s", err.Error()) } return errors.New(e) } // New create a new client func New(config types.AdGuardInstance) (Client, error) { var apiURL string if config.APIPath == "" { apiURL = fmt.Sprintf("%s/control", config.URL) } else { apiURL = fmt.Sprintf("%s/%s", config.URL, config.APIPath) } u, err := url.Parse(apiURL) if err != nil { return nil, err } u.Path = path.Clean(u.Path) cl := resty.New().SetBaseURL(u.String()).SetDisableWarn(true) // #nosec G402 has to be explicitly enabled cl.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: config.InsecureSkipVerify}) cookieParts := strings.Split(config.Cookie, "=") if len(cookieParts) == 2 { cl.SetCookie(&http.Cookie{ Name: cookieParts[0], Value: cookieParts[1], }) } else if config.Username != "" && config.Password != "" { cl = cl.SetBasicAuth(config.Username, config.Password) } if v, ok := os.LookupEnv(envRedirectPolicyNoOfRedirects); ok { nbr, err := strconv.Atoi(v) if err != nil { return nil, fmt.Errorf("error parsing env var %q value must be an integer", envRedirectPolicyNoOfRedirects) } cl.SetRedirectPolicy(resty.FlexibleRedirectPolicy(nbr)) } else { // no redirect cl.SetRedirectPolicy(resty.NoRedirectPolicy()) } return &client{ host: config.Host, client: cl, log: l.With("host", config.Host), }, nil } // Client AdguardHome API client interface type Client interface { Host() string Status() (*model.ServerStatus, error) Stats() (*model.Stats, error) QueryLog(limit int) (*model.QueryLog, error) ToggleProtection(enable bool) error RewriteList() (*model.RewriteEntries, error) AddRewriteEntries(e ...model.RewriteEntry) error DeleteRewriteEntries(e ...model.RewriteEntry) error Filtering() (*model.FilterStatus, error) ToggleFiltering(enabled bool, interval int) error AddFilter(whitelist bool, f model.Filter) error DeleteFilter(whitelist bool, f model.Filter) error UpdateFilter(whitelist bool, f model.Filter) error RefreshFilters(whitelist bool) error SetCustomRules(rules *[]string) error SafeBrowsing() (bool, error) ToggleSafeBrowsing(enable bool) error Parental() (bool, error) ToggleParental(enable bool) error SafeSearchConfig() (*model.SafeSearchConfig, error) SetSafeSearchConfig(settings *model.SafeSearchConfig) error ProfileInfo() (*model.ProfileInfo, error) SetProfileInfo(settings *model.ProfileInfo) error BlockedServicesSchedule() (*model.BlockedServicesSchedule, error) SetBlockedServicesSchedule(schedule *model.BlockedServicesSchedule) error Clients() (*model.Clients, error) AddClient(client *model.Client) error UpdateClient(client *model.Client) error DeleteClient(client *model.Client) error QueryLogConfig() (*model.QueryLogConfigWithIgnored, error) SetQueryLogConfig(*model.QueryLogConfigWithIgnored) error StatsConfig() (*model.GetStatsConfigResponse, error) SetStatsConfig(sc *model.PutStatsConfigUpdateRequest) error Setup() error AccessList() (*model.AccessList, error) SetAccessList(*model.AccessList) error DNSConfig() (*model.DNSConfig, error) SetDNSConfig(*model.DNSConfig) error DhcpConfig() (*model.DhcpStatus, error) SetDhcpConfig(*model.DhcpStatus) error AddDHCPStaticLease(lease model.DhcpStaticLease) error DeleteDHCPStaticLease(lease model.DhcpStaticLease) error } type client struct { client *resty.Client log *zap.SugaredLogger host string version string } func (cl *client) Host() string { return cl.host } func contentType(resp *resty.Response) string { if ct, ok := resp.Header()["Content-Type"]; ok { if len(ct) != 1 { return fmt.Sprintf("%v", ct) } return ct[0] } return "" } func (cl *client) Status() (*model.ServerStatus, error) { status := &model.ServerStatus{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(status), "status") cl.version = status.Version return status, err } func (cl *client) Stats() (*model.Stats, error) { stats := &model.Stats{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(stats), "stats") return stats, err } func (cl *client) QueryLog(limit int) (*model.QueryLog, error) { ql := &model.QueryLog{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(ql), fmt.Sprintf(`querylog?limit=%d&response_status="all"`, limit)) return ql, err } func (cl *client) RewriteList() (*model.RewriteEntries, error) { rewrites := &model.RewriteEntries{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(&rewrites), "/rewrite/list") return rewrites, err } func (cl *client) AddRewriteEntries(entries ...model.RewriteEntry) error { for _, e := range entries { cl.log.With("domain", e.Domain, "answer", e.Answer).Info("Add DNS rewrite entry") err := cl.doPost(cl.client.R().EnableTrace().SetBody(&e), "/rewrite/add") if err != nil { return err } } return nil } func (cl *client) DeleteRewriteEntries(entries ...model.RewriteEntry) error { for _, e := range entries { cl.log.With("domain", e.Domain, "answer", e.Answer).Info("Delete DNS rewrite entry") err := cl.doPost(cl.client.R().EnableTrace().SetBody(&e), "/rewrite/delete") if err != nil { return err } } return nil } func (cl *client) SafeBrowsing() (bool, error) { return cl.toggleStatus("safebrowsing") } func (cl *client) ToggleSafeBrowsing(enable bool) error { return cl.toggleBool("safebrowsing", enable) } func (cl *client) Parental() (bool, error) { return cl.toggleStatus("parental") } func (cl *client) ToggleParental(enable bool) error { return cl.toggleBool("parental", enable) } func (cl *client) toggleStatus(mode string) (bool, error) { fs := &model.EnableConfig{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(fs), fmt.Sprintf("/%s/status", mode)) return fs.Enabled, err } func (cl *client) toggleBool(mode string, enable bool) error { cl.log.With("enable", enable).Info(fmt.Sprintf("Toggle %s", mode)) var target string if enable { target = "enable" } else { target = "disable" } return cl.doPost(cl.client.R().EnableTrace(), fmt.Sprintf("/%s/%s", mode, target)) } func (cl *client) Filtering() (*model.FilterStatus, error) { f := &model.FilterStatus{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(f), "/filtering/status") return f, err } func (cl *client) AddFilter(whitelist bool, f model.Filter) error { cl.log.With("url", f.Url, "whitelist", whitelist, "enabled", f.Enabled).Info("Add filter") ff := &model.AddUrlRequest{Name: utils.Ptr(f.Name), Url: utils.Ptr(f.Url), Whitelist: utils.Ptr(whitelist)} return cl.doPost(cl.client.R().EnableTrace().SetBody(ff), "/filtering/add_url") } func (cl *client) DeleteFilter(whitelist bool, f model.Filter) error { cl.log.With("url", f.Url, "whitelist", whitelist, "enabled", f.Enabled).Info("Delete filter") ff := &model.RemoveUrlRequest{Url: utils.Ptr(f.Url), Whitelist: utils.Ptr(whitelist)} return cl.doPost(cl.client.R().EnableTrace().SetBody(ff), "/filtering/remove_url") } func (cl *client) UpdateFilter(whitelist bool, f model.Filter) error { cl.log.With("url", f.Url, "whitelist", whitelist, "enabled", f.Enabled).Info("Update filter") fu := &model.FilterSetUrl{ Whitelist: utils.Ptr(whitelist), Url: utils.Ptr(f.Url), Data: &model.FilterSetUrlData{Name: f.Name, Url: f.Url, Enabled: f.Enabled}, } return cl.doPost(cl.client.R().EnableTrace().SetBody(fu), "/filtering/set_url") } func (cl *client) RefreshFilters(whitelist bool) error { cl.log.With("whitelist", whitelist).Info("Refresh filter") return cl.doPost(cl.client.R().EnableTrace().SetBody(&model.FilterRefreshRequest{Whitelist: utils.Ptr(whitelist)}), "/filtering/refresh") } func (cl *client) ToggleProtection(enable bool) error { cl.log.With("enable", enable).Info("Toggle protection") return cl.doPost(cl.client.R().EnableTrace().SetBody(&types.Protection{ProtectionEnabled: enable}), "/dns_config") } func (cl *client) SetCustomRules(rules *[]string) error { var l int if rules != nil { l = len(*rules) } cl.log.With("rules", l).Info("Set user rules") return cl.doPost(cl.client.R().EnableTrace().SetBody(&model.SetRulesRequest{Rules: rules}), "/filtering/set_rules") } func (cl *client) ToggleFiltering(enabled bool, interval int) error { cl.log.With("enabled", enabled, "interval", interval).Info("Toggle filtering") return cl.doPost(cl.client.R().EnableTrace().SetBody(&model.FilterConfig{ Enabled: utils.Ptr(enabled), Interval: utils.Ptr(interval), }), "/filtering/config") } func (cl *client) BlockedServicesSchedule() (*model.BlockedServicesSchedule, error) { sched := &model.BlockedServicesSchedule{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(sched), "/blocked_services/get") return sched, err } func (cl *client) SetBlockedServicesSchedule(schedule *model.BlockedServicesSchedule) error { cl.log.With("services", schedule.ServicesString()).Info("Set blocked services schedule") return cl.doPut(cl.client.R().EnableTrace().SetBody(schedule), "/blocked_services/update") } func (cl *client) Clients() (*model.Clients, error) { clients := &model.Clients{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(clients), "/clients") return clients, err } func (cl *client) AddClient(client *model.Client) error { cl.log.With("name", *client.Name).Info("Add client settings") return cl.doPost(cl.client.R().EnableTrace().SetBody(client), "/clients/add") } func (cl *client) UpdateClient(client *model.Client) error { cl.log.With("name", *client.Name).Info("Update client settings") return cl.doPost(cl.client.R().EnableTrace().SetBody(&model.ClientUpdate{Name: client.Name, Data: client}), "/clients/update") } func (cl *client) DeleteClient(client *model.Client) error { cl.log.With("name", *client.Name).Info("Delete client settings") return cl.doPost(cl.client.R().EnableTrace().SetBody(client), "/clients/delete") } func (cl *client) QueryLogConfig() (*model.QueryLogConfigWithIgnored, error) { qlc := &model.QueryLogConfigWithIgnored{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(qlc), "/querylog/config") return qlc, err } func (cl *client) SetQueryLogConfig(qlc *model.QueryLogConfigWithIgnored) error { cl.log.With("enabled", *qlc.Enabled, "interval", *qlc.Interval, "anonymizeClientIP", *qlc.AnonymizeClientIp).Info("Set query log config") return cl.doPut(cl.client.R().EnableTrace().SetBody(qlc), "/querylog/config/update") } func (cl *client) StatsConfig() (*model.GetStatsConfigResponse, error) { stats := &model.GetStatsConfigResponse{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(stats), "/stats/config") return stats, err } func (cl *client) SetStatsConfig(sc *model.PutStatsConfigUpdateRequest) error { cl.log.With("interval", sc.Interval).Info("Set stats config") return cl.doPut(cl.client.R().EnableTrace().SetBody(sc), "/stats/config/update") } func (cl *client) Setup() error { cl.log.Info("Setup new AdguardHome instance") cfg := &types.InstallConfig{ Web: types.InstallPort{ IP: "0.0.0.0", Port: 3000, Status: "", CanAutofix: false, }, DNS: types.InstallPort{ IP: "0.0.0.0", Port: 53, Status: "", CanAutofix: false, }, } if cl.client.UserInfo != nil { cfg.Username = cl.client.UserInfo.Username cfg.Password = cl.client.UserInfo.Password } req := cl.client.R().EnableTrace().SetBody(cfg) req.UserInfo = nil return cl.doPost(req, "/install/configure") } func (cl *client) AccessList() (*model.AccessList, error) { al := &model.AccessList{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(al), "/access/list") return al, err } func (cl *client) SetAccessList(list *model.AccessList) error { cl.log.Info("Set access list") return cl.doPost(cl.client.R().EnableTrace().SetBody(list), "/access/set") } func (cl *client) DNSConfig() (*model.DNSConfig, error) { cfg := &model.DNSConfig{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(cfg), "/dns_info") return cfg, err } func (cl *client) SetDNSConfig(config *model.DNSConfig) error { cl.log.Info("Set dns config list") return cl.doPost(cl.client.R().EnableTrace().SetBody(config), "/dns_config") } func (cl *client) DhcpConfig() (*model.DhcpStatus, error) { cfg := &model.DhcpStatus{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(cfg), "/dhcp/status") return cfg, err } func (cl *client) SetDhcpConfig(config *model.DhcpStatus) error { cl.log.Info("Set dhcp server config") return cl.doPost(cl.client.R().EnableTrace().SetBody(config), "/dhcp/set_config") } func (cl *client) AddDHCPStaticLease(l model.DhcpStaticLease) error { cl.log.With("mac", l.Mac, "ip", l.Ip, "hostname", l.Hostname).Info("Add static dhcp lease") err := cl.doPost(cl.client.R().EnableTrace().SetBody(l), "/dhcp/add_static_lease") if err != nil { return err } return nil } func (cl *client) DeleteDHCPStaticLease(l model.DhcpStaticLease) error { cl.log.With("mac", l.Mac, "ip", l.Ip, "hostname", l.Hostname).Info("Delete static dhcp lease") err := cl.doPost(cl.client.R().EnableTrace().SetBody(l), "/dhcp/remove_static_lease") if err != nil { return err } return nil } func (cl *client) SafeSearchConfig() (*model.SafeSearchConfig, error) { sss := &model.SafeSearchConfig{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(sss), "/safesearch/status") return sss, err } func (cl *client) SetSafeSearchConfig(settings *model.SafeSearchConfig) error { cl.log.With("enabled", *settings.Enabled).Info("Set safesearch settings") return cl.doPut(cl.client.R().EnableTrace().SetBody(settings), "/safesearch/settings") } func (cl *client) ProfileInfo() (*model.ProfileInfo, error) { p := &model.ProfileInfo{} err := cl.doGet(cl.client.R().EnableTrace().SetResult(p), "/profile") return p, err } func (cl *client) SetProfileInfo(profile *model.ProfileInfo) error { cl.log.With("language", profile.Language, "theme", profile.Theme).Info("Set profile") return cl.doPut(cl.client.R().EnableTrace().SetBody(profile), "/profile/update") }