diff --git a/client/src/components/Settings/Clients/Form/components/MainSettings.tsx b/client/src/components/Settings/Clients/Form/components/MainSettings.tsx index 258ff677a49..420d220225b 100644 --- a/client/src/components/Settings/Clients/Form/components/MainSettings.tsx +++ b/client/src/components/Settings/Clients/Form/components/MainSettings.tsx @@ -5,6 +5,11 @@ import i18next from 'i18next'; import { captitalizeWords } from '../../../../../helpers/helpers'; import { ClientForm } from '../types'; import { Checkbox } from '../../../../ui/Controls/Checkbox'; +import { Radio } from '../../../../ui/Controls/Radio'; +import { Input } from '../../../../ui/Controls/Input'; +import { BLOCKING_MODES } from '../../../../../helpers/constants'; +import { validateIpv4, validateIpv6, validateRequiredValue } from '../../../../../helpers/validators'; + type ProtectionSettings = 'use_global_settings' | 'filtering_enabled' | 'safebrowsing_enabled' | 'parental_enabled'; @@ -43,14 +48,60 @@ const logAndStatsCheckboxes: { name: LogsStatsSettings; placeholder: string }[] }, ]; +const customIps: { + name: 'blocking_ipv4' | 'blocking_ipv6'; + label: string; + description: string; + validateIp: (value: string) => string; +}[] = [ + { + name: 'blocking_ipv4', + label: i18next.t('blocking_ipv4'), + description: i18next.t('blocking_ipv4_desc'), + validateIp: validateIpv4, + }, + { + name: 'blocking_ipv6', + label: i18next.t('blocking_ipv6'), + description: i18next.t('blocking_ipv6_desc'), + validateIp: validateIpv6, + }, +]; + +const blockingModeOptions = [ + { + value: BLOCKING_MODES.default, + label: i18next.t('default'), + }, + { + value: BLOCKING_MODES.refused, + label: i18next.t('refused'), + }, + { + value: BLOCKING_MODES.nxdomain, + label: i18next.t('nxdomain'), + }, + { + value: BLOCKING_MODES.null_ip, + label: i18next.t('null_ip'), + }, + { + value: BLOCKING_MODES.custom_ip, + label: i18next.t('custom_ip'), + }, +]; + type Props = { safeSearchServices: Record; + processingAdding: boolean; + processingUpdating: boolean; }; -export const MainSettings = ({ safeSearchServices }: Props) => { +export const MainSettings = ({ processingAdding, processingUpdating, safeSearchServices }: Props) => { const { t } = useTranslation(); const { watch, control } = useFormContext(); + const blockingMode = watch('blocking_mode'); const useGlobalSettings = watch('use_global_settings'); return ( @@ -107,6 +158,53 @@ export const MainSettings = ({ safeSearchServices }: Props) => { ))} +
+ + +
+ ( + + )} + /> +
+
+ {blockingMode === BLOCKING_MODES.custom_ip && ( + <> + {customIps.map(({ label, description, name, validateIp }) => ( +
+
+ ( + + )} + /> +
+
+ ))} + + )} +
{t('log_and_stats_section_label')}
diff --git a/client/src/components/Settings/Clients/Form/index.tsx b/client/src/components/Settings/Clients/Form/index.tsx index de5cc7b3c76..f5bd3ed3268 100644 --- a/client/src/components/Settings/Clients/Form/index.tsx +++ b/client/src/components/Settings/Clients/Form/index.tsx @@ -5,7 +5,7 @@ import { Controller, FormProvider, useForm } from 'react-hook-form'; import Select from 'react-select'; import Tabs from '../../../ui/Tabs'; -import { CLIENT_ID_LINK, LOCAL_TIMEZONE_VALUE } from '../../../../helpers/constants'; +import { CLIENT_ID_LINK, LOCAL_TIMEZONE_VALUE, BLOCKING_MODES } from '../../../../helpers/constants'; import { RootState } from '../../../../initialState'; import { Input } from '../../../ui/Controls/Input'; import { validateRequiredValue } from '../../../../helpers/validators'; @@ -33,6 +33,9 @@ const defaultFormValues: ClientForm = { blocked_services_schedule: { time_zone: LOCAL_TIMEZONE_VALUE, }, + blocking_mode: BLOCKING_MODES.default, + blocking_ipv4: '', + blocking_ipv6: '', }; type Props = { @@ -83,7 +86,10 @@ export const Form = ({ const tabs = { settings: { title: 'settings', - component: , + component: , }, block_services: { title: 'block_services', diff --git a/client/src/components/Settings/Clients/Form/types.ts b/client/src/components/Settings/Clients/Form/types.ts index 72bf394bbb7..f3d82f2ff3d 100644 --- a/client/src/components/Settings/Clients/Form/types.ts +++ b/client/src/components/Settings/Clients/Form/types.ts @@ -20,6 +20,9 @@ export type ClientForm = { parental_enabled: boolean; ignore_querylog: boolean; ignore_statistics: boolean; + blocking_mode: string; + blocking_ipv4: string; + blocking_ipv6: string; }; export type SubmitClientForm = Omit & { diff --git a/internal/client/persistent.go b/internal/client/persistent.go index 4ec3695ef5d..c11fd67d959 100644 --- a/internal/client/persistent.go +++ b/internal/client/persistent.go @@ -65,6 +65,15 @@ type Persistent struct { // must not be nil after initialization. BlockedServices *filtering.BlockedServices + // BlockingMode is the blocking mode override for a client + BlockingMode filtering.BlockingMode + + // BlockingIPv4 is the IP address to be returned for a blocked A request. + BlockingIPv4 netip.Addr + + // BlockingIPv6 is the IP address to be returned for a blocked AAAA request. + BlockingIPv6 netip.Addr + // Name of the persistent client. Must not be empty. Name string @@ -140,6 +149,10 @@ func (c *Persistent) validate(ctx context.Context, l *slog.Logger, allTags []str return errors.Error("uid required") } + if e := c.validateBlockingMode(); e != nil { + return e + } + conf, err := proxy.ParseUpstreamsConfig(c.Upstreams, &upstream.Options{}) if err != nil { return fmt.Errorf("invalid upstream servers: %w", err) @@ -163,6 +176,27 @@ func (c *Persistent) validate(ctx context.Context, l *slog.Logger, allTags []str return nil } +func (c *Persistent) validateBlockingMode() error { + switch c.BlockingMode { + case + filtering.BlockingModeDefault, + filtering.BlockingModeNXDOMAIN, + filtering.BlockingModeREFUSED, + filtering.BlockingModeNullIP: + return nil + case filtering.BlockingModeCustomIP: + if !c.BlockingIPv4.Is4() { + return fmt.Errorf("blocking_ipv4 must be valid ipv4 on custom_ip blocking_mode") + } + if !c.BlockingIPv6.Is6() { + return fmt.Errorf("blocking_ipv6 must be valid ipv6 on custom_ip blocking_mode") + } + } + + // Assumed to default to the default blocking mode + return nil +} + // SetIDs parses a list of strings into typed fields and returns an error if // there is one. func (c *Persistent) SetIDs(ids []string) (err error) { @@ -295,6 +329,10 @@ func (c *Persistent) ShallowClone() (clone *Persistent) { clone = &Persistent{} *clone = *c + clone.BlockingMode = c.BlockingMode + clone.BlockingIPv4 = c.BlockingIPv4 + clone.BlockingIPv6 = c.BlockingIPv6 + clone.BlockedServices = c.BlockedServices.Clone() clone.Tags = slices.Clone(c.Tags) clone.Upstreams = slices.Clone(c.Upstreams) diff --git a/internal/client/storage.go b/internal/client/storage.go index 1a156a11cbc..0247dfb76c7 100644 --- a/internal/client/storage.go +++ b/internal/client/storage.go @@ -705,4 +705,7 @@ func (s *Storage) ApplyClientFiltering(id string, addr netip.Addr, setts *filter setts.ClientSafeSearch = c.SafeSearch setts.SafeBrowsingEnabled = c.SafeBrowsingEnabled setts.ParentalEnabled = c.ParentalEnabled + setts.BlockingMode = c.BlockingMode + setts.BlockingIPv4 = c.BlockingIPv4 + setts.BlockingIPv6 = c.BlockingIPv6 } diff --git a/internal/dnsforward/filter.go b/internal/dnsforward/filter.go index 6cfd7bea754..437a63fc2e5 100644 --- a/internal/dnsforward/filter.go +++ b/internal/dnsforward/filter.go @@ -45,7 +45,7 @@ func (s *Server) filterDNSRequest(dctx *dnsContext) (res *filtering.Result, err req.Question[0].Name = dns.Fqdn(res.CanonName) case res.IsFiltered: log.Debug("dnsforward: host %q is filtered, reason: %q", host, res.Reason) - pctx.Res = s.genDNSFilterMessage(pctx, res) + pctx.Res = s.genDNSFilterMessage(dctx, pctx, res) case res.Reason.In(filtering.Rewritten, filtering.FilteredSafeSearch): pctx.Res = s.getCNAMEWithIPs(req, res.IPList, res.CanonName) case res.Reason.In(filtering.RewrittenRule, filtering.RewrittenAutoHosts): @@ -130,7 +130,7 @@ func (s *Server) filterDNSResponse(dctx *dnsContext) (err error) { } else if res != nil && res.IsFiltered { dctx.result = res dctx.origResp = pctx.Res - pctx.Res = s.genDNSFilterMessage(pctx, res) + pctx.Res = s.genDNSFilterMessage(dctx, pctx, res) log.Debug("dnsforward: matched %q by response: %q", pctx.Req.Question[0].Name, host) diff --git a/internal/dnsforward/msg.go b/internal/dnsforward/msg.go index e9f1f2d77f1..0961ab27fde 100644 --- a/internal/dnsforward/msg.go +++ b/internal/dnsforward/msg.go @@ -47,13 +47,14 @@ func ipsFromRules(resRules []*filtering.ResultRule) (ips []netip.Addr) { // genDNSFilterMessage generates a filtered response to req for the filtering // result res. func (s *Server) genDNSFilterMessage( - dctx *proxy.DNSContext, + dctx *dnsContext, + pctx *proxy.DNSContext, res *filtering.Result, ) (resp *dns.Msg) { - req := dctx.Req + req := pctx.Req qt := req.Question[0].Qtype if qt != dns.TypeA && qt != dns.TypeAAAA && qt != dns.TypeHTTPS { - m, _, _ := s.dnsFilter.BlockingMode() + m, _, _ := s.blockingMode(dctx) if m == filtering.BlockingModeNullIP { return s.replyCompressed(req) } @@ -63,16 +64,16 @@ func (s *Server) genDNSFilterMessage( switch res.Reason { case filtering.FilteredSafeBrowsing: - return s.genBlockedHost(req, s.dnsFilter.SafeBrowsingBlockHost(), dctx) + return s.genBlockedHost(req, s.dnsFilter.SafeBrowsingBlockHost(), pctx) case filtering.FilteredParental: - return s.genBlockedHost(req, s.dnsFilter.ParentalBlockHost(), dctx) + return s.genBlockedHost(req, s.dnsFilter.ParentalBlockHost(), pctx) case filtering.FilteredSafeSearch: // If Safe Search generated the necessary IP addresses, use them. // Otherwise, if there were no errors, there are no addresses for the // requested IP version, so produce a NODATA response. return s.getCNAMEWithIPs(req, ipsFromRules(res.Rules), res.CanonName) default: - return s.genForBlockingMode(req, ipsFromRules(res.Rules)) + return s.genForBlockingMode(dctx, req, ipsFromRules(res.Rules)) } } @@ -112,8 +113,8 @@ func (s *Server) getCNAMEWithIPs(req *dns.Msg, ips []netip.Addr, cname string) ( // genForBlockingMode generates a filtered response to req based on the server's // blocking mode. -func (s *Server) genForBlockingMode(req *dns.Msg, ips []netip.Addr) (resp *dns.Msg) { - switch mode, bIPv4, bIPv6 := s.dnsFilter.BlockingMode(); mode { +func (s *Server) genForBlockingMode(dctx *dnsContext, req *dns.Msg, ips []netip.Addr) (resp *dns.Msg) { + switch mode, bIPv4, bIPv6 := s.blockingMode(dctx); mode { case filtering.BlockingModeCustomIP: return s.makeResponseCustomIP(req, bIPv4, bIPv6) case filtering.BlockingModeDefault: diff --git a/internal/dnsforward/process.go b/internal/dnsforward/process.go index 259aeff2c26..fea9f78ced7 100644 --- a/internal/dnsforward/process.go +++ b/internal/dnsforward/process.go @@ -621,6 +621,11 @@ func (s *Server) processFilteringAfterResponse(dctx *dnsContext) (rc resultCode) } } +// blockingMode fetches the blocking mode for the specific client from the context +func (s *Server) blockingMode(dctx *dnsContext) (filtering.BlockingMode, netip.Addr, netip.Addr) { + return dctx.setts.BlockingMode, dctx.setts.BlockingIPv4, dctx.setts.BlockingIPv6 +} + // filterAfterResponse returns the result of filtering the response that wasn't // explicitly allowed or rewritten. func (s *Server) filterAfterResponse(dctx *dnsContext) (res resultCode) { diff --git a/internal/filtering/filter.go b/internal/filtering/filter.go index 14572d01a6a..38078d9d650 100644 --- a/internal/filtering/filter.go +++ b/internal/filtering/filter.go @@ -638,6 +638,7 @@ func (d *DNSFilter) ApplyAdditionalFiltering(cliAddr netip.Addr, clientID string d.ApplyBlockedServices(setts) d.applyClientFiltering(clientID, cliAddr, setts) + if setts.BlockedServices != nil { // TODO(e.burkov): Get rid of this crutch. setts.ServicesRules = nil diff --git a/internal/filtering/filtering.go b/internal/filtering/filtering.go index df65308f690..f98d55850b1 100644 --- a/internal/filtering/filtering.go +++ b/internal/filtering/filtering.go @@ -53,6 +53,10 @@ type Settings struct { // is nil if the client does not have any blocked services. BlockedServices *BlockedServices + BlockingMode BlockingMode + BlockingIPv4 netip.Addr + BlockingIPv6 netip.Addr + ProtectionEnabled bool FilteringEnabled bool SafeSearchEnabled bool @@ -387,6 +391,9 @@ func (d *DNSFilter) Settings() (s *Settings) { SafeSearchEnabled: d.conf.SafeSearchConf.Enabled, SafeBrowsingEnabled: d.conf.SafeBrowsingEnabled, ParentalEnabled: d.conf.ParentalEnabled, + BlockingMode: d.conf.BlockingMode, + BlockingIPv4: d.conf.BlockingIPv4, + BlockingIPv6: d.conf.BlockingIPv6, } } diff --git a/internal/home/clients.go b/internal/home/clients.go index 781e5e9d232..558218d95be 100644 --- a/internal/home/clients.go +++ b/internal/home/clients.go @@ -159,6 +159,10 @@ type clientObject struct { Tags []string `yaml:"tags"` Upstreams []string `yaml:"upstreams"` + BlockingMode filtering.BlockingMode `yaml:"blocking_mode"` + BlockingIPv4 netip.Addr `yaml:"blocking_ipv4"` + BlockingIPv6 netip.Addr `yaml:"blocking_ipv6"` + // UID is the unique identifier of the persistent client. UID client.UID `yaml:"uid"` @@ -194,6 +198,10 @@ func (o *clientObject) toPersistent( UID: o.UID, + BlockingMode: o.BlockingMode, + BlockingIPv4: o.BlockingIPv4, + BlockingIPv6: o.BlockingIPv6, + UseOwnSettings: !o.UseGlobalSettings, FilteringEnabled: o.FilteringEnabled, ParentalEnabled: o.ParentalEnabled, @@ -273,6 +281,10 @@ func (clients *clientsContainer) forConfig() (objs []*clientObject) { Tags: slices.Clone(cli.Tags), Upstreams: slices.Clone(cli.Upstreams), + BlockingMode: cli.BlockingMode, + BlockingIPv4: cli.BlockingIPv4, + BlockingIPv6: cli.BlockingIPv6, + UID: cli.UID, UseGlobalSettings: !cli.UseOwnSettings, diff --git a/internal/home/clientshttp.go b/internal/home/clientshttp.go index 2971dfeae88..554a9b9b260 100644 --- a/internal/home/clientshttp.go +++ b/internal/home/clientshttp.go @@ -57,6 +57,10 @@ type clientJSON struct { UseGlobalBlockedServices bool `json:"use_global_blocked_services"` UseGlobalSettings bool `json:"use_global_settings"` + BlockingMode filtering.BlockingMode `json:"blocking_mode"` + BlockingIPv4 string `json:"blocking_ipv4"` + BlockingIPv6 string `json:"blocking_ipv6"` + IgnoreQueryLog aghalg.NullBool `json:"ignore_querylog"` IgnoreStatistics aghalg.NullBool `json:"ignore_statistics"` @@ -171,7 +175,15 @@ func initPrev(cj clientJSON, prev *client.Persistent) (c *client.Persistent, err } } + blockingMode, blockingIPv4, blockingIPv6, err := copyBlockingMode(cj.BlockingMode, cj.BlockingIPv4, cj.BlockingIPv6) + if err != nil { + return nil, err + } + return &client.Persistent{ + BlockingMode: blockingMode, + BlockingIPv4: blockingIPv4, + BlockingIPv6: blockingIPv6, BlockedServices: svcs, UID: uid, IgnoreQueryLog: ignoreQueryLog, @@ -181,6 +193,32 @@ func initPrev(cj clientJSON, prev *client.Persistent) (c *client.Persistent, err }, nil } +func copyBlockingMode(blockingMode filtering.BlockingMode, ipv4, ipv6 string) (filtering.BlockingMode, netip.Addr, netip.Addr, error) { + switch blockingMode { + case filtering.BlockingModeDefault, + filtering.BlockingModeNXDOMAIN, + filtering.BlockingModeREFUSED, + filtering.BlockingModeNullIP: + + return blockingMode, netip.Addr{}, netip.Addr{}, nil + case filtering.BlockingModeCustomIP: + + addrIpv4, err := netip.ParseAddr(ipv4) + if err != nil { + return blockingMode, netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid blocking_ipv4 address: %s", err.Error()) + } + + addrIpv6, err := netip.ParseAddr(ipv6) + if err != nil { + return blockingMode, netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid blocking_ipv6 address: %s", err.Error()) + } + + return blockingMode, addrIpv4, addrIpv6, nil + } + + return filtering.BlockingModeDefault, netip.Addr{}, netip.Addr{}, nil +} + // jsonToClient converts JSON object to persistent client object if there are no // errors. func (clients *clientsContainer) jsonToClient( @@ -210,6 +248,11 @@ func (clients *clientsContainer) jsonToClient( c.SafeBrowsingEnabled = cj.SafeBrowsingEnabled c.UseOwnBlockedServices = !cj.UseGlobalBlockedServices + c.BlockingMode, c.BlockingIPv4, c.BlockingIPv6, err = copyBlockingMode(cj.BlockingMode, cj.BlockingIPv4, cj.BlockingIPv6) + if err != nil { + return nil, err + } + if c.SafeSearchConf.Enabled { logger := clients.baseLogger.With( slogutil.KeyPrefix, safesearch.LogPrefix, @@ -309,6 +352,10 @@ func clientToJSON(c *client.Persistent) (cj *clientJSON) { SafeSearchConf: safeSearchConf, SafeBrowsingEnabled: c.SafeBrowsingEnabled, + BlockingMode: c.BlockingMode, + BlockingIPv4: c.BlockingIPv4.String(), + BlockingIPv6: c.BlockingIPv6.String(), + UseGlobalBlockedServices: !c.UseOwnBlockedServices, Schedule: c.BlockedServices.Schedule, diff --git a/internal/home/config.go b/internal/home/config.go index 23cdd7fe622..7eccfececcc 100644 --- a/internal/home/config.go +++ b/internal/home/config.go @@ -567,10 +567,24 @@ func parseConfig() (err error) { config.DNS.UpstreamTimeout = timeutil.Duration(dnsforward.DefaultTimeout) } + setDefaultsForPersistentClients() + // Do not wrap the error because it's informative enough as is. return validateTLSCipherIDs(config.TLS.OverrideTLSCiphers) } +// setDefaultsForPersistentClients sets some default values for persistent clients +// derived from global defaults +func setDefaultsForPersistentClients() { + for i, o := range config.Clients.Persistent { + if o.BlockingMode == "" { + config.Clients.Persistent[i].BlockingMode = config.Filtering.BlockingMode + config.Clients.Persistent[i].BlockingIPv4 = config.Filtering.BlockingIPv4 + config.Clients.Persistent[i].BlockingIPv6 = config.Filtering.BlockingIPv6 + } + } +} + // validateConfig returns error if the configuration is invalid. func validateConfig() (err error) { err = validateBindHosts(config)