diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6fb63881..044b9688 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@ and this project adheres to
 
 ### Added
 
+- Upstream server information for responses from cache ([#3772]).  Note that old
+  log entries concerning cached responses won't include that information.
 - Finnish and Ukrainian translations.
 - Setting the timeout for IP address pinging in the "Fastest IP address" mode
   through the new `fastest_timeout` field in the configuration file ([#1992]).
@@ -47,6 +49,7 @@ and this project adheres to
 
 ### Changed
 
+- Responses from cache are now labeled ([#3772]).
 - Better error message for ED25519 private keys, which are not widely supported
   ([#3737]).
 - Cache now follows RFC more closely for negative answers ([#3707]).
@@ -223,6 +226,7 @@ In this release, the schema version has changed from 10 to 12.
 [#3655]: https://github.com/AdguardTeam/AdGuardHome/issues/3655
 [#3707]: https://github.com/AdguardTeam/AdGuardHome/issues/3707
 [#3744]: https://github.com/AdguardTeam/AdGuardHome/issues/3744
+[#3772]: https://github.com/AdguardTeam/AdGuardHome/issues/3772
 [#3815]: https://github.com/AdguardTeam/AdGuardHome/issues/3815
 [#3890]: https://github.com/AdguardTeam/AdGuardHome/issues/3890
 
diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index 9caf8845..edf63255 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -47,7 +47,6 @@
     "form_error_server_name": "Invalid server name",
     "form_error_subnet": "Subnet \"{{cidr}}\" does not contain the IP address \"{{ip}}\"",
     "form_error_positive": "Must be greater than 0",
-    "form_error_negative": "Must be equal to 0 or greater",
     "out_of_range_error": "Must be out of range \"{{start}}\"-\"{{end}}\"",
     "lower_range_start_error": "Must be lower than range start",
     "greater_range_start_error": "Must be greater than range start",
@@ -627,5 +626,6 @@
     "experimental": "Experimental",
     "use_saved_key": "Use the previously saved key",
     "parental_control": "Parental control",
-    "safe_browsing": "Safe browsing"
+    "safe_browsing": "Safe browsing",
+    "served_from_cache": "{{value}} <i>(served from cache)</i>"
 }
diff --git a/client/src/components/Logs/Cells/ResponseCell.js b/client/src/components/Logs/Cells/ResponseCell.js
index 3a3aeb60..772b89e5 100644
--- a/client/src/components/Logs/Cells/ResponseCell.js
+++ b/client/src/components/Logs/Cells/ResponseCell.js
@@ -21,6 +21,7 @@ const ResponseCell = ({
     upstream,
     rules,
     service_name,
+    cached,
 }) => {
     const { t } = useTranslation();
     const filters = useSelector((state) => state.filtering.filters, shallowEqual);
@@ -36,6 +37,9 @@ const ResponseCell = ({
 
     const statusLabel = t(isBlockedByResponse ? 'blocked_by_cname_or_ip' : FILTERED_STATUS_TO_META_MAP[reason]?.LABEL || reason);
     const boldStatusLabel = <span className="font-weight-bold">{statusLabel}</span>;
+    const upstreamString = cached
+        ? t('served_from_cache', { value: upstream, i: <i /> })
+        : upstream;
 
     const renderResponses = (responseArr) => {
         if (!responseArr || responseArr.length === 0) {
@@ -53,7 +57,7 @@ const ResponseCell = ({
 
     const COMMON_CONTENT = {
         encryption_status: boldStatusLabel,
-        install_settings_dns: upstream,
+        install_settings_dns: upstreamString,
         elapsed: formattedElapsedMs,
         response_code: status,
         ...(service_name
@@ -90,8 +94,9 @@ const ResponseCell = ({
 
     const detailedInfo = getDetailedInfo(reason);
 
-    return <div className="logs__cell logs__cell--response" role="gridcell">
-        <IconTooltip
+    return (
+        <div className="logs__cell logs__cell--response" role="gridcell">
+            <IconTooltip
                 className={classNames('icons mr-4 icon--24 icon--lightgray', { 'my-3': isDetailed })}
                 columnClass='grid grid--limited'
                 tooltipClass='px-5 pb-5 pt-4 mw-75 custom-tooltip__response-details'
@@ -100,14 +105,15 @@ const ResponseCell = ({
                 title='response_details'
                 content={content}
                 placement='bottom'
-        />
-        <div className="text-truncate">
-            <div className="text-truncate" title={statusLabel}>{statusLabel}</div>
-            {isDetailed && <div
-                    className="detailed-info d-none d-sm-block pt-1 text-truncate"
-                    title={detailedInfo}>{detailedInfo}</div>}
+            />
+            <div className="text-truncate">
+                <div className="text-truncate" title={statusLabel}>{statusLabel}</div>
+                {isDetailed && <div
+                        className="detailed-info d-none d-sm-block pt-1 text-truncate"
+                        title={detailedInfo}>{detailedInfo}</div>}
+            </div>
         </div>
-    </div>;
+    );
 };
 
 ResponseCell.propTypes = {
@@ -117,6 +123,7 @@ ResponseCell.propTypes = {
     response: propTypes.array.isRequired,
     status: propTypes.string.isRequired,
     upstream: propTypes.string.isRequired,
+    cached: propTypes.bool.isRequired,
     rules: propTypes.arrayOf(propTypes.shape({
         text: propTypes.string.isRequired,
         filter_list_id: propTypes.number.isRequired,
diff --git a/client/src/components/Logs/Cells/index.js b/client/src/components/Logs/Cells/index.js
index df36f1d2..2287f8d1 100644
--- a/client/src/components/Logs/Cells/index.js
+++ b/client/src/components/Logs/Cells/index.js
@@ -76,6 +76,7 @@ const Row = memo(({
             originalResponse,
             status,
             service_name,
+            cached,
         } = rowProps;
 
         const hasTracker = !!tracker;
@@ -116,6 +117,9 @@ const Row = memo(({
 
         const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only';
         const clientNameBlockingFor = getBlockingClientName(clients, client);
+        const upstreamString = cached
+            ? t('served_from_cache', { value: upstream, i: <i /> })
+            : upstream;
 
         const onBlockingForClientClick = () => {
             dispatch(toggleBlockingForClient(buttonType, domain, clientNameBlockingFor));
@@ -175,7 +179,7 @@ const Row = memo(({
                             className="link--green">{sourceData.name}
                     </a>,
             response_details: 'title',
-            install_settings_dns: upstream,
+            install_settings_dns: upstreamString,
             elapsed: formattedElapsedMs,
             ...(rules.length > 0
                     && { rule_label: getRulesToFilterList(rules, filters, whitelistFilters) }
@@ -230,6 +234,7 @@ Row.propTypes = {
         time: propTypes.string.isRequired,
         tracker: propTypes.object,
         upstream: propTypes.string.isRequired,
+        cached: propTypes.bool.isRequired,
         type: propTypes.string.isRequired,
         client_proto: propTypes.string.isRequired,
         client_id: propTypes.string,
diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js
index 4c9803ae..8f4344ee 100644
--- a/client/src/helpers/helpers.js
+++ b/client/src/helpers/helpers.js
@@ -75,6 +75,7 @@ export const normalizeLogs = (logs) => logs.map((log) => {
         service_name,
         original_answer,
         upstream,
+        cached,
     } = log;
 
     const { name: domain, unicode_name: unicodeName, type } = question;
@@ -116,6 +117,7 @@ export const normalizeLogs = (logs) => logs.map((log) => {
         answer_dnssec,
         elapsedMs,
         upstream,
+        cached,
     };
 });
 
diff --git a/client/src/helpers/validators.js b/client/src/helpers/validators.js
index 2ac98a6b..2ebf6a30 100644
--- a/client/src/helpers/validators.js
+++ b/client/src/helpers/validators.js
@@ -230,17 +230,6 @@ export const validateMac = (value) => {
     return undefined;
 };
 
-/**
- * @param value {number}
- * @returns {boolean|*}
- */
-export const validateBiggerOrEqualZeroValue = (value) => {
-    if (value < 0) {
-        return 'form_error_negative';
-    }
-    return false;
-};
-
 /**
  * @param value {number}
  * @returns {undefined|string}
diff --git a/go.mod b/go.mod
index 7c032047..9d4593ca 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,8 @@ module github.com/AdguardTeam/AdGuardHome
 go 1.16
 
 require (
-	github.com/AdguardTeam/dnsproxy v0.39.9
-	github.com/AdguardTeam/golibs v0.10.2
+	github.com/AdguardTeam/dnsproxy v0.39.12
+	github.com/AdguardTeam/golibs v0.10.3
 	github.com/AdguardTeam/urlfilter v0.15.0
 	github.com/NYTimes/gziphandler v1.1.1
 	github.com/ameshkov/dnscrypt/v2 v2.2.2
diff --git a/go.sum b/go.sum
index 6db8fa82..969ec191 100644
--- a/go.sum
+++ b/go.sum
@@ -9,13 +9,13 @@ dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D
 git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
 github.com/AdguardTeam/dhcp v0.0.0-20210519141215-51808c73c0bf h1:gc042VRSIRSUzZ+Px6xQCRWNJZTaPkomisDfUZmoFNk=
 github.com/AdguardTeam/dhcp v0.0.0-20210519141215-51808c73c0bf/go.mod h1:TKl4jN3Voofo4UJIicyNhWGp/nlQqQkFxmwIFTvBkKI=
-github.com/AdguardTeam/dnsproxy v0.39.9 h1:lH4lKA7KHKFJZgzlij1YAVX6v7eIQpUFpYh9qV+WfGw=
-github.com/AdguardTeam/dnsproxy v0.39.9/go.mod h1:eDpJKAdkHORRwAedjuERv+7SWlcz4cn+5uwrbUAWHRY=
+github.com/AdguardTeam/dnsproxy v0.39.12 h1:BxAfdQLGnu0rqhD23K5nNw09sKQeqpT2AkFEE78WOqU=
+github.com/AdguardTeam/dnsproxy v0.39.12/go.mod h1:g7zjF1TWpKNeDVh6h3YrjQN8565zsWRd7zo++C/935c=
 github.com/AdguardTeam/golibs v0.4.0/go.mod h1:skKsDKIBB7kkFflLJBpfGX+G8QFTx0WKUzB6TIgtUj4=
 github.com/AdguardTeam/golibs v0.4.2/go.mod h1:skKsDKIBB7kkFflLJBpfGX+G8QFTx0WKUzB6TIgtUj4=
 github.com/AdguardTeam/golibs v0.9.2/go.mod h1:fCAMwPBJ8S7YMYbTWvYS+eeTLblP5E04IDtNAo7y7IY=
-github.com/AdguardTeam/golibs v0.10.2 h1:TAwnS4Y49sSUa4UX1yz/MWNGbIlXHqafrWr9MxdIh9A=
-github.com/AdguardTeam/golibs v0.10.2/go.mod h1:rSfQRGHIdgfxriDDNgNJ7HmE5zRoURq8R+VdR81Zuzw=
+github.com/AdguardTeam/golibs v0.10.3 h1:FBgk17zf35ESVWQKIqEUiqqB2bDaCBC8X5vMU760yB4=
+github.com/AdguardTeam/golibs v0.10.3/go.mod h1:rSfQRGHIdgfxriDDNgNJ7HmE5zRoURq8R+VdR81Zuzw=
 github.com/AdguardTeam/gomitmproxy v0.2.0/go.mod h1:Qdv0Mktnzer5zpdpi5rAwixNJzW2FN91LjKJCkVbYGU=
 github.com/AdguardTeam/urlfilter v0.15.0 h1:K3WWZE0K5nPTHe2l+TRXDFpYWJJnvkHdlWidt6NQUTk=
 github.com/AdguardTeam/urlfilter v0.15.0/go.mod h1:EwXwrYhowP7bedqmOrmKKmQtpBYFyDNEBFQ+lxdUgQU=
@@ -64,7 +64,6 @@ github.com/go-ole/go-ole v1.2.5 h1:t4MGB5xEDZvXI+0rMjjsfBsD7yAgp/s9ZDkL1JndXwY=
 github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
 github.com/go-ping/ping v0.0.0-20210506233800-ff8be3320020 h1:mdi6AbCEoKCA1xKCmp7UtRB5fvGFlP92PvlhxgdvXEw=
 github.com/go-ping/ping v0.0.0-20210506233800-ff8be3320020/go.mod h1:KmHOjTUmJh/l04ukqPoBWPEZr9jwN05h5NXQl5C+DyY=
-github.com/go-test/deep v1.0.5 h1:AKODKU3pDH1RzZzm6YZu77YWtEAq6uh1rLIAQlay2qc=
 github.com/go-test/deep v1.0.5/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
diff --git a/internal/dnsforward/stats.go b/internal/dnsforward/stats.go
index abdb519e..67af8ff9 100644
--- a/internal/dnsforward/stats.go
+++ b/internal/dnsforward/stats.go
@@ -27,11 +27,12 @@ func (s *Server) processQueryLogsAndStats(ctx *dnsContext) (rc resultCode) {
 		shouldLog = false
 	}
 
+	ip, _ := netutil.IPAndPortFromAddr(pctx.Addr)
+	ip = netutil.CloneIP(ip)
+
 	s.serverLock.RLock()
 	defer s.serverLock.RUnlock()
 
-	ip, _ := netutil.IPAndPortFromAddr(pctx.Addr)
-	ip = netutil.CloneIP(ip)
 	s.anonymizer.Load()(ip)
 
 	log.Debug("client ip: %s", ip)
@@ -60,12 +61,14 @@ func (s *Server) processQueryLogsAndStats(ctx *dnsContext) (rc resultCode) {
 		case proxy.ProtoDNSCrypt:
 			p.ClientProto = querylog.ClientProtoDNSCrypt
 		default:
-			// Consider this a plain DNS-over-UDP or DNS-over-TCP
-			// request.
+			// Consider this a plain DNS-over-UDP or DNS-over-TCP request.
 		}
 
 		if pctx.Upstream != nil {
 			p.Upstream = pctx.Upstream.Address()
+		} else if cachedUps := pctx.CachedUpstreamAddr; cachedUps != "" {
+			p.Upstream = pctx.CachedUpstreamAddr
+			p.Cached = true
 		}
 
 		s.queryLog.Add(p)
diff --git a/internal/querylog/decode.go b/internal/querylog/decode.go
index d23fc526..65c8ada1 100644
--- a/internal/querylog/decode.go
+++ b/internal/querylog/decode.go
@@ -44,8 +44,10 @@ var logEntryHandlers = map[string]logEntryHandler{
 		if !ok {
 			return nil
 		}
+
 		var err error
 		ent.Time, err = time.Parse(time.RFC3339, v)
+
 		return err
 	},
 	"QH": func(t json.Token, ent *logEntry) error {
@@ -69,7 +71,9 @@ var logEntryHandlers = map[string]logEntryHandler{
 		if !ok {
 			return nil
 		}
+
 		ent.QClass = v
+
 		return nil
 	},
 	"CP": func(t json.Token, ent *logEntry) error {
@@ -77,8 +81,10 @@ var logEntryHandlers = map[string]logEntryHandler{
 		if !ok {
 			return nil
 		}
+
 		var err error
 		ent.ClientProto, err = NewClientProto(v)
+
 		return err
 	},
 	"Answer": func(t json.Token, ent *logEntry) error {
@@ -86,8 +92,10 @@ var logEntryHandlers = map[string]logEntryHandler{
 		if !ok {
 			return nil
 		}
+
 		var err error
 		ent.Answer, err = base64.StdEncoding.DecodeString(v)
+
 		return err
 	},
 	"OrigAnswer": func(t json.Token, ent *logEntry) error {
@@ -95,16 +103,30 @@ var logEntryHandlers = map[string]logEntryHandler{
 		if !ok {
 			return nil
 		}
+
 		var err error
 		ent.OrigAnswer, err = base64.StdEncoding.DecodeString(v)
+
 		return err
 	},
+	"Cached": func(t json.Token, ent *logEntry) error {
+		v, ok := t.(bool)
+		if !ok {
+			return nil
+		}
+
+		ent.Cached = v
+
+		return nil
+	},
 	"Upstream": func(t json.Token, ent *logEntry) error {
 		v, ok := t.(string)
 		if !ok {
 			return nil
 		}
+
 		ent.Upstream = v
+
 		return nil
 	},
 	"Elapsed": func(t json.Token, ent *logEntry) error {
@@ -112,11 +134,14 @@ var logEntryHandlers = map[string]logEntryHandler{
 		if !ok {
 			return nil
 		}
+
 		i, err := v.Int64()
 		if err != nil {
 			return err
 		}
+
 		ent.Elapsed = time.Duration(i)
+
 		return nil
 	},
 }
diff --git a/internal/querylog/decode_test.go b/internal/querylog/decode_test.go
index d57b24f0..fe4e085e 100644
--- a/internal/querylog/decode_test.go
+++ b/internal/querylog/decode_test.go
@@ -33,6 +33,7 @@ func TestDecodeLogEntry(t *testing.T) {
 			`"QC":"IN",` +
 			`"CP":"",` +
 			`"Answer":"` + ansStr + `",` +
+			`"Cached":true,` +
 			`"Result":{` +
 			`"IsFiltered":true,` +
 			`"Reason":3,` +
@@ -42,6 +43,7 @@ func TestDecodeLogEntry(t *testing.T) {
 			`"CanonName":"example.com",` +
 			`"ServiceName":"example.org",` +
 			`"DNSRewriteResult":{"RCode":0,"Response":{"1":["127.0.0.2"]}}},` +
+			`"Upstream":"https://some.upstream",` +
 			`"Elapsed":837429}`
 
 		ans, err := base64.StdEncoding.DecodeString(ansStr)
@@ -56,6 +58,7 @@ func TestDecodeLogEntry(t *testing.T) {
 			ClientID:    "cli42",
 			ClientProto: "",
 			Answer:      ans,
+			Cached:      true,
 			Result: filtering.Result{
 				IsFiltered: true,
 				Reason:     filtering.FilteredBlockList,
@@ -78,7 +81,8 @@ func TestDecodeLogEntry(t *testing.T) {
 					},
 				},
 			},
-			Elapsed: 837429,
+			Upstream: "https://some.upstream",
+			Elapsed:  837429,
 		}
 
 		got := &logEntry{}
diff --git a/internal/querylog/json.go b/internal/querylog/json.go
index 23953f80..6f59f1d5 100644
--- a/internal/querylog/json.go
+++ b/internal/querylog/json.go
@@ -74,6 +74,7 @@ func (l *queryLog) entryToJSON(entry *logEntry, anonFunc aghnet.IPMutFunc) (json
 		"time":         entry.Time.Format(time.RFC3339Nano),
 		"client":       eip,
 		"client_proto": entry.ClientProto,
+		"cached":       entry.Cached,
 		"upstream":     entry.Upstream,
 		"question":     question,
 	}
diff --git a/internal/querylog/qlog.go b/internal/querylog/qlog.go
index 48f0d351..f04eecb5 100644
--- a/internal/querylog/qlog.go
+++ b/internal/querylog/qlog.go
@@ -87,10 +87,11 @@ type logEntry struct {
 
 	Answer     []byte `json:",omitempty"` // sometimes empty answers happen like binerdunt.top or rev2.globalrootservers.net
 	OrigAnswer []byte `json:",omitempty"`
+	Cached     bool   `json:",omitempty"`
 
 	Result   filtering.Result
 	Elapsed  time.Duration
-	Upstream string `json:",omitempty"` // if empty, means it was cached
+	Upstream string `json:",omitempty"`
 }
 
 func (l *queryLog) Start() {
@@ -171,6 +172,7 @@ func (l *queryLog) Add(params AddParams) {
 		Result:      *params.Result,
 		Elapsed:     params.Elapsed,
 		Upstream:    params.Upstream,
+		Cached:      params.Cached,
 		ClientID:    params.ClientID,
 		ClientProto: params.ClientProto,
 	}
diff --git a/internal/querylog/querylog.go b/internal/querylog/querylog.go
index 58b5a8e0..0b39c01e 100644
--- a/internal/querylog/querylog.go
+++ b/internal/querylog/querylog.go
@@ -73,16 +73,24 @@ type Config struct {
 	Anonymizer *aghnet.IPMut
 }
 
-// AddParams - parameters for Add()
+// AddParams is the parameters for adding an entry.
 type AddParams struct {
-	Question    *dns.Msg
-	Answer      *dns.Msg          // The response we sent to the client (optional)
-	OrigAnswer  *dns.Msg          // The response from an upstream server (optional)
-	Result      *filtering.Result // Filtering result (optional)
-	Elapsed     time.Duration     // Time spent for processing the request
-	ClientID    string
-	ClientIP    net.IP
-	Upstream    string // Upstream server URL
+	Question *dns.Msg
+	// Answer is the response which is sent to the client, if any.
+	Answer *dns.Msg
+	// OrigAnswer is the response from an upstream server.  It's only set if the
+	// answer has been modified by filtering.
+	OrigAnswer *dns.Msg
+	// Cached indicates if the response is served from cache.
+	Cached bool
+	// Result is the filtering result (optional).
+	Result *filtering.Result
+	// Elapsed is the time spent for processing the request.
+	Elapsed  time.Duration
+	ClientID string
+	ClientIP net.IP
+	// Upstream is the URL of the upstream DNS server.
+	Upstream    string
 	ClientProto ClientProto
 }
 
diff --git a/openapi/CHANGELOG.md b/openapi/CHANGELOG.md
index 7fceb418..558490f2 100644
--- a/openapi/CHANGELOG.md
+++ b/openapi/CHANGELOG.md
@@ -4,6 +4,11 @@
 
 ## v0.107: API changes
 
+## The new field `"cached"` in `QueryLogItem`
+
+* The new field `"cached"` in `GET /control/querylog` is true if the response is
+  served from cache instead of being resolved by an upstream server.
+
 ### New constant values for `filter_list_id` field in `ResultRule`
 
 * Value of `0` is now used for custom filtering rules list.
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index aa2d5aa3..ea5aa05b 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -1868,6 +1868,10 @@
           'description': 'Answer from upstream server (optional)'
           'items':
             '$ref': '#/components/schemas/DnsAnswer'
+        'cached':
+          'type': 'boolean'
+          'description': >
+            Defines if the response has been served from cache.
         'upstream':
           'type': 'string'
           'description': >