Repository navigation
Expand file tree
/
Copy pathhaproxy_agent.py
More file actions
3952 lines (3457 loc) · 174 KB
/
Copy pathhaproxy_agent.py
File metadata and controls
3952 lines (3457 loc) · 174 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""An agent for HaProxy that takes care of most of the authentication logic of AppAPI. Python 3.12 required."""
# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
import asyncio
import collections
import hashlib
import hmac
import io
import ipaddress
import json
import logging
import os
import posixpath
import re
import socket
import ssl
import tarfile
import time
from base64 import b64encode
from enum import IntEnum
from ipaddress import IPv4Address, IPv6Address, ip_address
from typing import Any, Literal, Self
from urllib.parse import quote, unquote
import aiohttp
from aiohttp import web
from haproxyspoa.payloads.ack import AckPayload
from haproxyspoa.spoa_server import SpoaServer
from pydantic import (
BaseModel,
Field,
ValidationError,
computed_field,
field_validator,
model_validator,
)
# Reported by `/info`; AppAPI compares it against its minimum supported HaRP version.
# Keep it in sync with the release tag and use a full `x.y.z` string: a bare float like `0.4`
# is read as "0.4.0", which made every release look older than it is.
HARP_VERSION = "0.4.7"
APPID_PATTERN = re.compile(r"(?:^|/)exapps/([^/]+)")
# Matches `.` and `..` path segments in any literal/encoded form: separators can be `/`, %2F or `\` (a slash for WHATWG
# URL parsers), the dots `.` or %2E (any case), and the segment may go on with `;` or %3B (Tomcat drops parameters),
# or end at %3F or %23 (a wrapper ExApp that decodes the path cuts it there), as in AppAPI's PHP proxy.
DOT_SEGMENT_PATTERN = re.compile(r"(?:^|[/\\]|%2[fF])(?:\.|%2[eE]){1,2}(?:[/;\\]|%2[fF]|%3[bBfF]|%23|$)")
# An escaped RFC 3986 unreserved character (letters, digits, `-._~`): it means the same escaped or not.
UNRESERVED_ESCAPE_PATTERN = re.compile(r"%(?:[46][1-9A-Fa-f]|[57][0-9Aa]|3[0-9]|2[DdEe]|5[Ff]|7[Ee])")
LONE_PERCENT_PATTERN = re.compile(r"%(?![0-9A-Fa-f]{2})")
# The reply to HAProxy carries the forwarded path and must fit one SPOE frame (16380 bytes).
MAX_FORWARDED_PATH_LENGTH = 14 * 1024
CONTROL_CHARACTER_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
LIFECYCLE_ENDPOINTS = ("heartbeat", "init", "enabled")
SHARED_KEY = os.environ.get("HP_SHARED_KEY")
# ExApp containers get this token instead of the shared key (see `with_exapp_frp_token`). It is accepted only for
# the FRP login, so an ExApp cannot use the control API. Deployed containers carry it: the derivation must not change.
EXAPP_FRP_TOKEN = (
hmac.new(SHARED_KEY.encode("utf-8", "surrogatepass"), b"harp-frp-exapp-v1", hashlib.sha256).hexdigest()
if SHARED_KEY
else ""
)
NC_INSTANCE_URL = os.environ.get("NC_INSTANCE_URL")
SPOA_ADDRESS = os.environ.get("HP_SPOA_ADDRESS", "127.0.0.1:9600")
SPOA_HOST, SPOA_PORT = SPOA_ADDRESS.rsplit(":", 1)
SPOA_PORT = int(SPOA_PORT)
# Kubernetes environment variables
K8S_ENABLED = os.environ.get("HP_K8S_ENABLED", "false").lower() in {"1", "true", "yes"}
K8S_NAMESPACE = os.environ.get("HP_K8S_NAMESPACE", "nextcloud-exapps")
K8S_API_SERVER = os.environ.get("HP_K8S_API_SERVER") # e.g. https://kubernetes.default.svc
K8S_CA_FILE = os.environ.get("HP_K8S_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
K8S_TOKEN_ENV = os.environ.get("HP_K8S_BEARER_TOKEN") # static token from env (never rotated)
K8S_TOKEN_FILE = os.environ.get("HP_K8S_BEARER_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token")
K8S_VERIFY_SSL = os.environ.get("HP_K8S_VERIFY_SSL", "true").lower() != "false"
K8S_STORAGE_CLASS = os.environ.get("HP_K8S_STORAGE_CLASS", "")
K8S_DEFAULT_STORAGE_SIZE = os.environ.get("HP_K8S_DEFAULT_STORAGE_SIZE", "10Gi")
K8S_HOST_ALIASES_RAW = os.environ.get("HP_K8S_HOST_ALIASES", "") # "hostname:ip,hostname2:ip2"
def _in_cluster_api_server() -> str:
"""The URL of the API server whose address Kubernetes sets in every pod; an IPv6 address needs brackets."""
host = os.environ["KUBERNETES_SERVICE_HOST"]
port = os.environ.get("KUBERNETES_SERVICE_PORT", "443")
return f"https://[{host}]:{port}" if ":" in host else f"https://{host}:{port}"
if not K8S_API_SERVER and os.environ.get("KUBERNETES_SERVICE_HOST"):
K8S_API_SERVER = _in_cluster_api_server()
K8S_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=60.0)
# `/info` probes the API server with its own short timeout: AppAPI waits 5 s for `/info` in its daemon checks, so a
# probe that inherits K8S_HTTP_TIMEOUT makes an unreachable API server look like an unreachable HaRP.
K8S_PROBE_TIMEOUT = aiohttp.ClientTimeout(total=3.0)
_k8s_session: aiohttp.ClientSession | None = None
K8S_NAME_MAX_LENGTH = 63
# Set up the logging configuration
LOG_LEVEL = os.environ.get("HP_LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=LOG_LEVEL)
LOGGER = logging.getLogger(__name__)
LOGGER.setLevel(level=LOG_LEVEL)
logging.getLogger("haproxyspoa").setLevel(level=LOG_LEVEL)
logging.getLogger("aiohttp").setLevel(level=LOG_LEVEL)
NC_REQ_URL = NC_INSTANCE_URL.removesuffix("/").removesuffix("/index.php")
EX_APP_URL = f"{NC_REQ_URL}/index.php/apps/app_api/harp/exapp-meta"
USER_INFO_URL = f"{NC_REQ_URL}/index.php/apps/app_api/harp/user-info"
# Client headers the user-info lookup leaves out, as it does every content-* header
EXCLUDE_HEADERS_USER_INFO = {
"accept-encoding",
"connection",
"expect",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"range",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
# aiohttp refuses to send a header that contains one of these
UNSENDABLE_HEADER_CHARS = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
# Keep total below the SPOE `timeout processing` (30s); aiohttp's default is 5 minutes.
NC_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=25.0, connect=5.0)
_nc_session: aiohttp.ClientSession | None = None
SPOA_AGENT = SpoaServer()
DOCKER_API_HOST = "127.0.0.1"
# The ports the FRP server accepts Docker Engine tunnels on (see `allowPorts` in start.sh); HP_DOCKER_ENGINE_PORTS
# can add others for setups where HaRP shares its network namespace with the Docker Engine or a socket proxy.
DEFAULT_DOCKER_ENGINE_PORTS = "24000-24099"
# Headers whose values are never written to the log.
REDACTED_LOG_HEADERS = frozenset({"harp-shared-key", "authorization-app-api", "authorization", "cookie"})
def _parse_trusted_proxies(value: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
if not value:
return []
networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
invalid: list[str] = []
masked: list[tuple[str, ipaddress.IPv4Network | ipaddress.IPv6Network]] = []
dequoted: list[tuple[str, str]] = []
for raw_item in value.split(","):
# Quotes reach us literally when the variable is set via an env-file or a compose
# `environment:` list entry, so strip them before parsing.
item = raw_item.strip()
stripped = item.strip("'\"\u201c\u201d\u2018\u2019").strip()
if not stripped:
continue
try:
networks.append(ipaddress.ip_network(stripped))
if stripped != item:
dequoted.append((item, stripped))
continue
except ValueError:
pass
try:
network = ipaddress.ip_network(stripped, strict=False)
except ValueError:
invalid.append(item)
continue
if stripped != item:
dequoted.append((item, stripped))
masked.append((stripped, network))
networks.append(network)
# The rescues below run before the trust summary; at the default "warning" log level they are
# the only boot-time signal that a previously rejected value now enables header-based detection.
for item, stripped in dequoted[:5]:
LOGGER.warning("HP_TRUSTED_PROXY_IPS: quotes stripped from %r, using %r.", item, stripped)
if len(dequoted) > 5:
LOGGER.warning("HP_TRUSTED_PROXY_IPS: quotes stripped from %d more entries.", len(dequoted) - 5)
# Same leniency as Nextcloud's own `trusted_proxies` handling: mask the host bits.
for item, network in masked[:5]:
LOGGER.warning("HP_TRUSTED_PROXY_IPS: interpreting %r as %r.", item, str(network))
if len(masked) > 5:
LOGGER.warning("HP_TRUSTED_PROXY_IPS: %d more entries with host bits set were masked.", len(masked) - 5)
for bad in invalid[:5]:
LOGGER.error("Invalid entry in HP_TRUSTED_PROXY_IPS ignored: %r is not an IPv4 or IPv6 network.", bad)
if len(invalid) > 5:
LOGGER.error("HP_TRUSTED_PROXY_IPS: %d more invalid entries ignored.", len(invalid) - 5)
if networks:
LOGGER.info("Trusting reverse proxies for client IP detection: %s", [str(p) for p in networks])
else:
LOGGER.error(
"No valid entries in HP_TRUSTED_PROXY_IPS (%r). Client IP detection from headers is disabled. "
"The X-Forwarded-For and X-Real-IP headers will not be respected. "
"This can lead to the outer proxy's IP being blocked "
"during a bruteforce attempt instead of the actual client's IP.",
value if len(value) <= 200 else value[:200] + "...",
)
return networks
TRUSTED_PROXIES = _parse_trusted_proxies(os.environ.get("HP_TRUSTED_PROXY_IPS", ""))
# The entries of the default list after the hard core only log a warning in this release (see
# HP_EXAPP_BIND_MOUNTS_ENFORCE_ALL): AppAPI re-sends the mounts stored at install on every ExApp update, after it
# has removed the old container, so refusing a mount that used to work would leave the ExApp without a container.
_STAGED_BIND_MOUNTS_DENIED = (
"/dev,/run,/var/run,/root,/var/lib/docker/volumes,/var/lib/containers/storage/volumes,"
"/etc:rw,/usr:rw,/bin:rw,/sbin:rw,/lib:rw,/lib64:rw,/var/spool:rw"
)
# Host paths ExApp containers may not bind mount unless HP_EXAPP_BIND_MOUNTS_DENIED says otherwise: the hard core
# (kernel interfaces, the container runtimes' sockets and state, host credentials), which no ExApp has a reason to
# mount; then device and runtime trees, root's home, the Docker and Podman volume directories, and read-write access
# to the host's programs and spool directories.
DEFAULT_BIND_MOUNTS_DENIED = (
"/proc,/sys,/boot,/var/lib/docker,/var/lib/containerd,/var/lib/containers,"
"/run/docker.sock,/var/run/docker.sock,/run/containerd,/var/run/containerd,"
"/run/podman,/var/run/podman,/run/crio,/var/run/crio,"
"/etc/shadow,/etc/gshadow,/etc/sudoers,/etc/sudoers.d,/etc/ssh," + _STAGED_BIND_MOUNTS_DENIED
)
DEFAULT_BIND_MOUNTS_STAGED = frozenset(_STAGED_BIND_MOUNTS_DENIED.split(","))
def _has_control_characters(value: str) -> bool:
return any(ord(char) < 32 or ord(char) == 127 for char in value)
def _setting_items(value: str) -> list[str]:
"""Split a comma-separated setting; quotes reach us literally from an env-file or a compose list entry."""
return [item for raw in value.split(",") if (item := raw.strip().strip("'\"").strip())]
def _normalize_mount_path(value: str) -> str:
"""Return the normalized form of an absolute path; raise ValueError for anything else."""
if not value.startswith("/"):
raise ValueError("must be an absolute path")
if ":" in value:
raise ValueError("must not contain ':'")
if _has_control_characters(value):
raise ValueError("must not contain control characters")
if ".." in value.split("/"):
raise ValueError("must not contain '..' segments")
return posixpath.normpath("/" + value.lstrip("/"))
def _parse_mount_rules(env_name: str, value: str, rw_suffix: bool = False) -> list[tuple[str, bool]]:
"""Parse a comma-separated list of host paths into (path, read-write only) rules.
A malformed entry stops the agent: silently dropping it would change which mounts are permitted.
"""
rules: list[tuple[str, bool]] = []
for item in _setting_items(value):
rw_only = rw_suffix and item.endswith(":rw")
if rw_only:
item = item.removesuffix(":rw").strip().strip("'\"").strip()
words = item.split()
if len(words) > 1 and all(word.startswith("/") for word in words):
raise SystemExit(f"ERROR: invalid entry {item!r} in {env_name}: separate the paths with commas.")
try:
rules.append((_normalize_mount_path(item), rw_only))
except ValueError as e:
raise SystemExit(f"ERROR: invalid entry {item!r} in {env_name}: {e}.") from None
return rules
def _parse_bool(env_name: str) -> bool:
"""Read a true/false setting that defaults to false; anything else stops the agent instead of meaning false."""
value = os.environ.get(env_name, "")
item = value.strip().strip("'\"").strip().lower()
if item in {"", "0", "false", "no"}:
return False
if item in {"1", "true", "yes"}:
return True
raise SystemExit(f"ERROR: invalid value {value!r} in {env_name}: expected true or false.")
def _rule_label(path: str, rw_only: bool) -> str:
return f"{path}:rw" if rw_only else path
BIND_MOUNTS_DISABLED = _parse_bool("HP_EXAPP_BIND_MOUNTS_DISABLED")
BIND_MOUNTS_ENFORCE_ALL = _parse_bool("HP_EXAPP_BIND_MOUNTS_ENFORCE_ALL")
BIND_MOUNTS_ALLOWED = [
path
for path, _ in _parse_mount_rules(
"HP_EXAPP_BIND_MOUNTS_ALLOWED", os.environ.get("HP_EXAPP_BIND_MOUNTS_ALLOWED", "")
)
]
_denied_setting = os.environ.get("HP_EXAPP_BIND_MOUNTS_DENIED")
BIND_MOUNTS_DENIED = _parse_mount_rules(
"HP_EXAPP_BIND_MOUNTS_DENIED",
DEFAULT_BIND_MOUNTS_DENIED if _denied_setting is None else _denied_setting,
rw_suffix=True,
)
# Rules that only log a warning: the staged entries of the default list. A list the administrator sets is
# always enforced as written.
BIND_MOUNTS_WARN_ONLY = (
frozenset() if _denied_setting is not None or BIND_MOUNTS_ENFORCE_ALL else DEFAULT_BIND_MOUNTS_STAGED
)
if _denied_setting is not None:
if BIND_MOUNTS_DENIED:
LOGGER.warning("HP_EXAPP_BIND_MOUNTS_DENIED replaces the default list of denied host paths.")
else:
LOGGER.warning("HP_EXAPP_BIND_MOUNTS_DENIED is empty: no host path is denied to ExApp containers.")
if BIND_MOUNTS_DISABLED:
LOGGER.info("Bind mounts for ExApp containers are disabled.")
else:
LOGGER.info(
"Bind mounts for ExApp containers: allowed below %s, denied %s%s.",
BIND_MOUNTS_ALLOWED or "any path",
[_rule_label(path, rw_only) for path, rw_only in BIND_MOUNTS_DENIED] or "nothing",
f" (only a warning for {sorted(BIND_MOUNTS_WARN_ONLY)})" if BIND_MOUNTS_WARN_ONLY else "",
)
def _parse_port_ranges(env_name: str, value: str) -> list[tuple[int, int]]:
"""Parse comma-separated ports ("2375") and ranges ("24000-24099"); a malformed entry stops the agent."""
ranges: list[tuple[int, int]] = []
for item in _setting_items(value):
first, dash, last = item.partition("-")
try:
first_port = int(first)
last_port = int(last) if dash else first_port
except ValueError:
raise SystemExit(
f"ERROR: invalid entry {item!r} in {env_name}: expected a port or a range like 24000-24099."
) from None
if not 1 <= first_port <= last_port <= 65535:
raise SystemExit(f"ERROR: invalid entry {item!r} in {env_name}: ports must be 1-65535, ranges ascending.")
ranges.append((first_port, last_port))
return ranges
# An empty value (e.g. a template passing an unset variable) means the default.
DOCKER_ENGINE_PORTS = _parse_port_ranges(
"HP_DOCKER_ENGINE_PORTS", os.environ.get("HP_DOCKER_ENGINE_PORTS", "")
) or _parse_port_ranges("HP_DOCKER_ENGINE_PORTS", DEFAULT_DOCKER_ENGINE_PORTS)
DOCKER_ENGINE_PORTS_TEXT = ", ".join(
str(first) if first == last else f"{first}-{last}" for first, last in DOCKER_ENGINE_PORTS
)
LOGGER.info("Docker Engine ports accepted from AppAPI: %s.", DOCKER_ENGINE_PORTS_TEXT)
###############################################################################
# Definitions
###############################################################################
class AccessLevel(IntEnum):
PUBLIC = 0
USER = 1
ADMIN = 2
class ExAppRoute(BaseModel):
url: str = Field(..., description="REGEX for URL, e.g. r'^/private/.*'")
verb: str | None = Field(None, description="HTTP methods, e.g. 'GET,POST'. Older AppAPI versions do not send it.")
access_level: AccessLevel = Field(..., description="ADMIN(2), USER(1), or PUBLIC(0)")
bruteforce_protection: list[int] = Field(
[], description="List with HTTP statuses to trigger the bruteforce protection."
)
str_bruteforce_protection: str = Field(
"", description="Private field, that will be automatically initialized from the 'bruteforce_protection' value."
)
@model_validator(mode="after")
def encode_bruteforce_protection_values(self) -> Self:
self.str_bruteforce_protection = json.dumps(self.bruteforce_protection) if self.bruteforce_protection else ""
return self
def methods(self) -> frozenset[str] | None:
"""The methods the route lists, or None (every method) when AppAPI did not send a verb."""
if self.verb is None:
return None
return frozenset(token.upper() for token in re.split(r"[\s,]+", self.verb) if token)
def accepts(self, method: str) -> bool:
"""HEAD is accepted wherever GET is. Methods are case-sensitive: a backend may treat `get` as not GET."""
methods = self.methods()
if methods is None:
return True
return method in methods or (method == "HEAD" and "GET" in methods)
class ExApp(BaseModel):
exapp_token: str = Field(..., repr=False) # the ExApp's secret; records are written to the log
exapp_version: str = Field(...)
host: str = Field(...)
port: int = Field(...)
routes: list[ExAppRoute] = Field([])
resolved_host: str = Field("", description="Contains resolved host field to the IP address.")
class NcUser(BaseModel):
user_id: str = Field("", description="The Nextcloud user ID if not an anonymous user.")
access_level: AccessLevel = Field(..., description="ADMIN(2), USER(1), or PUBLIC(0)")
def _sanitize_k8s_name(raw: str) -> str:
"""Convert an arbitrary string into a DNS-1123 compatible name for Kubernetes."""
name = raw.lower().replace("_", "-")
name = re.sub(r"[^a-z0-9-]", "-", name)
name = re.sub(r"-+", "-", name).strip("-")
if not name:
name = "exapp"
if len(name) > K8S_NAME_MAX_LENGTH:
name = name[:K8S_NAME_MAX_LENGTH].rstrip("-")
return name
class ExAppName(BaseModel):
# The three parts end up in container, volume and hostname values and in Docker API URLs,
# so they are limited to the characters Docker allows in a container name.
name: str = Field(..., pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$", max_length=63, description="ExApp name.")
instance_id: str = Field("", pattern=r"^[a-zA-Z0-9_.-]*$", max_length=63, description="Nextcloud instance ID.")
role_suffix: str = Field(
"", pattern=r"^[a-zA-Z0-9_.-]*$", max_length=63, description="Role suffix, e.g. 'rp', 'idx'."
)
@computed_field
@property
def exapp_container_name(self) -> str:
base = f"nc_app_{self.instance_id}_{self.name}" if self.instance_id else f"nc_app_{self.name}"
if self.role_suffix:
base = f"{base}_{self.role_suffix}"
return base
@computed_field
@property
def exapp_container_volume(self) -> str:
# Role-agnostic multirole K8s Deployments share one PVC and mount it at the same path as APP_PERSISTENT_STORAGE
base = f"nc_app_{self.instance_id}_{self.name}" if self.instance_id else f"nc_app_{self.name}"
return f"{base}_data"
@computed_field
@property
def exapp_k8s_name(self) -> str:
"""K8s Deployment name."""
return _sanitize_k8s_name(self.exapp_container_name)
@computed_field
@property
def exapp_k8s_volume_name(self) -> str:
"""K8s PVC name."""
base = _sanitize_k8s_name(self.exapp_container_volume)
if len(base) > K8S_NAME_MAX_LENGTH:
base = base[:K8S_NAME_MAX_LENGTH].rstrip("-")
return base
class CreateExAppMounts(BaseModel):
source: str = Field(...)
target: str = Field(...)
# Free text, as `occ app_api:app:register --mount SRC:DST:MODE` stores it; anything but "ro" mounts read-write.
mode: str = Field("rw")
@field_validator("source", "target")
@classmethod
def normalize_path(cls, value: str) -> str:
return _normalize_mount_path(value)
class CreateExAppPayload(ExAppName):
image_id: str = Field(..., description="Docker image ID.")
network_mode: str = Field(..., description="Desired NetworkMode for the container.")
environment_variables: list[str] = Field([], description="ExApp environment variables.")
restart_policy: Literal["", "no", "always", "unless-stopped", "on-failure"] = Field(
"unless-stopped", description="Desired RestartPolicy for the container."
)
compute_device: Literal["cpu", "rocm", "cuda"] = Field(
"cpu", description="Possible values: 'cpu', 'rocm' or 'cuda'"
)
mount_points: list[CreateExAppMounts] = Field([], description="List of mount points for the container.")
resource_limits: dict[str, Any] = Field({}, description="Resource limits for the container.")
image_pull_policy: Literal["IfNotPresent", "Never", "Always"] = Field(
"IfNotPresent", description="Kubernetes imagePullPolicy of the ExApp container (Kubernetes backend only)."
)
@model_validator(mode="before")
@classmethod
def accept_k8s_friendly_payload(cls, data: Any) -> Any:
"""Map 'image' -> 'image_id' and default network_mode for K8s payloads."""
if isinstance(data, dict):
if "image_id" not in data and "image" in data:
data = {**data, "image_id": data["image"]} # Allow 'image' instead of 'image_id'
if "network_mode" not in data:
data = {**data, "network_mode": "bridge"} # Default network_mode (used only for Docker)
return data
@field_validator("network_mode")
@classmethod
def validate_network_mode(cls, value: str) -> str:
"""Accept 'host', 'bridge' or a network name, but not the '<mode>:<argument>' forms like 'container:<id>'."""
separators = ":" in value or any(char.isspace() for char in value) or _has_control_characters(value)
if not value or len(value) > 255 or separators:
raise ValueError("must be 'host', 'bridge' or the name of a network")
return value
class RemoveExAppPayload(ExAppName):
remove_data: bool = Field(False, description="Flag indicating whether the Docker ExApp volume should be deleted.")
class InstallCertificatesPayload(ExAppName):
system_certs_bundle: str | None = Field(None, description="Content of the system CA bundle (concatenated PEMs).")
install_frp_certs: bool = Field(True, description="Flag to control installation of FRP certificates.")
class ExposeExAppPayload(ExAppName):
port: int = Field(..., ge=1, le=65535, description="Port on which the ExApp listens inside the Pod/container.")
expose_type: Literal["nodeport", "clusterip", "loadbalancer", "manual"] = Field("clusterip")
upstream_host: str | None = Field(None, description="Host override. Required for expose_type=manual.")
upstream_port: int | None = Field(None, ge=1, le=65535, description="Port override (manual only).")
service_port: int | None = Field(None, ge=1, le=65535, description="Service port (defaults to payload.port).")
node_port: int | None = Field(None, ge=30000, le=32767)
external_traffic_policy: Literal["Cluster", "Local"] | None = Field(None)
load_balancer_ip: str | None = Field(None)
service_annotations: dict[str, str] = Field(default_factory=dict)
service_labels: dict[str, str] = Field(default_factory=dict)
wait_timeout_seconds: float = Field(60.0, ge=0, le=600)
wait_interval_seconds: float = Field(1.0, ge=0.1, le=10.0)
node_address_type: Literal["InternalIP", "ExternalIP"] = Field("InternalIP")
node_name: str | None = Field(None)
node_label_selector: str | None = Field(None)
@model_validator(mode="after")
def validate_expose_payload(self) -> Self:
if self.expose_type == "manual" and not self.upstream_host:
raise ValueError("upstream_host is required when expose_type='manual'")
return self
class RemoveImagePayload(BaseModel):
image_ref: str = Field(
...,
min_length=1,
description="Docker image reference to remove (repo:tag, repo@digest, or image ID).",
)
###############################################################################
# In-memory caches
###############################################################################
EXAPP_CACHE: dict[str, ExApp] = {}
_EXAPP_INFLIGHT: dict[str, asyncio.Future[ExApp | None]] = {}
_EXAPP_NEGATIVE_CACHE: collections.OrderedDict[str, float] = collections.OrderedDict()
_NEGATIVE_CACHE_TTL = 15.0 # seconds to cache "ExApp not found" results
SESSION_CACHE_LOCK = asyncio.Lock()
SESSION_CACHE: dict[str, tuple[NcUser, float]] = {} # Stores NcUser and timestamp
SESSION_REQUEST_WINDOW = float(os.environ.get("HP_SESSION_LIFETIME", "3")) # Keep session information for 3 seconds
if SESSION_REQUEST_WINDOW < 0:
raise ValueError("`HP_SESSION_LIFETIME` cannot be less than 0")
if SESSION_REQUEST_WINDOW > 10:
raise ValueError("`HP_SESSION_LIFETIME` cannot be greater than 10")
BLACKLIST_CACHE_LOCK = asyncio.Lock()
BLACKLIST_CACHE: dict[str, list[float]] = {} # ip_str -> list of timestamps of failures
# 5 minutes in seconds
BLACKLIST_REQUEST_WINDOW = int(os.getenv("HP_BLACKLIST_WINDOW", "300"))
# 10 invalid attempts during BLACKLIST_REQUEST_WINDOW
BLACKLIST_MAX_FAILS_COUNT = int(os.getenv("HP_BLACKLIST_COUNT", "10"))
###############################################################################
# BLACKLIST CACHE functions
###############################################################################
def get_true_client_ip(
direct_ip: ipaddress.IPv4Address | ipaddress.IPv6Address, headers: dict[str, str]
) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
"""Determine the true client IP by inspecting headers from trusted proxies."""
if not TRUSTED_PROXIES:
return direct_ip
is_trusted = any(direct_ip in network for network in TRUSTED_PROXIES)
if not is_trusted:
return direct_ip
# The request is from a trusted proxy, so we can check the headers.
# X-Forwarded-For can be a list: client, proxy1, proxy2. We want the first one.
x_forwarded_for = headers.get("x-forwarded-for")
if x_forwarded_for:
true_ip_str = x_forwarded_for.split(",")[0].strip()
try:
return ipaddress.ip_address(true_ip_str)
except ValueError:
LOGGER.warning("Could not parse IP from X-Forwarded-For header: %s", true_ip_str)
x_real_ip = headers.get("x-real-ip")
if x_real_ip:
try:
return ipaddress.ip_address(x_real_ip)
except ValueError:
LOGGER.warning("Could not parse IP from X-Real-IP header: %s", x_real_ip)
return direct_ip # If headers are present but invalid, fall back to the direct IP of the proxy
async def record_ip_failure(ip_address: str | IPv4Address | IPv6Address) -> None:
"""Record a failed request attempt for this IP using BLACKLIST_CACHE."""
ip_str = str(ip_address)
now = time.time()
async with BLACKLIST_CACHE_LOCK:
attempts = BLACKLIST_CACHE.get(ip_str, [])
# Purge attempts that are older than the allowed window.
attempts = [ts for ts in attempts if now - ts < BLACKLIST_REQUEST_WINDOW]
attempts.append(now)
BLACKLIST_CACHE[ip_str] = attempts
LOGGER.warning("Recorded failure for IP %s. Failures in window: %d", ip_str, len(attempts))
def is_shared_key(value: Any) -> bool:
"""Compare a presented key with HP_SHARED_KEY in constant time; an unset or empty key never matches."""
if not SHARED_KEY or not isinstance(value, str):
return False
return hmac.compare_digest(value.encode("utf-8", "surrogatepass"), SHARED_KEY.encode("utf-8", "surrogatepass"))
def is_exapp_frp_token(value: Any) -> bool:
"""Compare a presented FRP token with the one ExApp containers get, in constant time."""
if not EXAPP_FRP_TOKEN or not isinstance(value, str):
return False
return hmac.compare_digest(value.encode("utf-8", "surrogatepass"), EXAPP_FRP_TOKEN.encode())
def with_exapp_frp_token(environment: list[str]) -> list[str]:
"""Replace the shared key that AppAPI gives an ExApp as `HP_SHARED_KEY` with the ExApp FRP token.
ExApps use it only as their frpc login token. Any other value is left as it is.
"""
result = []
for entry in environment:
name, _, value = entry.partition("=")
result.append(f"{name}={EXAPP_FRP_TOKEN}" if name == "HP_SHARED_KEY" and is_shared_key(value) else entry)
return result
async def record_failure_unless_trusted(
ip_address: str | IPv4Address | IPv6Address, request_headers: dict[str, str]
) -> None:
"""Record a blacklist failure unless the request is authenticated with a valid harp-shared-key.
Requests from Nextcloud/AppAPI carry `harp-shared-key` matching `SHARED_KEY`; a misconfiguration
on that trusted path (e.g. reverse-proxy stripping `/exapps/`) would otherwise cause the caller
to self-DoS once the blacklist window fills up.
"""
if is_shared_key(request_headers.get("harp-shared-key")):
return
await record_ip_failure(ip_address)
async def is_ip_banned(ip_address: str | IPv4Address | IPv6Address) -> bool:
"""Return True if IP has exceeded the maximum allowed failures in the request window."""
ip_str = str(ip_address)
now = time.time()
async with BLACKLIST_CACHE_LOCK:
attempts = BLACKLIST_CACHE.get(ip_str, [])
# Purge expired attempts.
attempts = [ts for ts in attempts if now - ts < BLACKLIST_REQUEST_WINDOW]
BLACKLIST_CACHE[ip_str] = attempts
if len(attempts) >= BLACKLIST_MAX_FAILS_COUNT:
return True
return False
###############################################################################
# SESSION CACHE functions
###############################################################################
def session_log_id(pass_cookie: str) -> str:
"""A short identifier for log lines about a session; the cookie value itself is never written."""
return hashlib.sha256(pass_cookie.encode("utf-8", "surrogatepass")).hexdigest()[:12]
async def record_session(pass_cookie: str, nc_user: NcUser) -> None:
now = time.time()
async with SESSION_CACHE_LOCK:
SESSION_CACHE[pass_cookie] = (nc_user, now)
LOGGER.info("Recorded session %s for user %s", session_log_id(pass_cookie), nc_user.user_id)
async def get_session(pass_cookie: str) -> NcUser | None:
"""Retrieve the session for the given IP address."""
now = time.time()
async with SESSION_CACHE_LOCK:
session_data = SESSION_CACHE.get(pass_cookie)
if session_data:
nc_user, timestamp = session_data
# Check if the session is still valid based on the SESSION_REQUEST_WINDOW
if now - timestamp <= SESSION_REQUEST_WINDOW:
return nc_user
# Session expired, remove it
del SESSION_CACHE[pass_cookie]
LOGGER.info("Session %s expired", session_log_id(pass_cookie))
return None
###############################################################################
# SPOA Handlers
###############################################################################
def _unescaped(match: re.Match) -> str:
return chr(int(match[0][1:], 16))
def exapp_paths(target_path: str) -> tuple[str, list[str]]:
"""The path to forward to the ExApp, and the ways servers read it, starting with the path its routes are checked on.
Servers differ on escaped unreserved characters, a `%` that starts no escape, repeated slashes and a raw backslash
(a slash for WHATWG URL parsers), so the forwarded path has none of them. The checked path is the forwarded one
decoded once, with `%2F` as a slash, and `//` merged, as for servers that decode before they route.
"""
forwarded = UNRESERVED_ESCAPE_PATTERN.sub(_unescaped, LONE_PERCENT_PATTERN.sub("%25", target_path))
forwarded = re.sub("//+", "/", forwarded.replace("\\", "%5C"))
if len(forwarded) > MAX_FORWARDED_PATH_LENGTH:
return forwarded, [forwarded] # refused by the caller
decoded = unquote(forwarded)
checked = re.sub("//+", "/", decoded)
readings = [checked, decoded] # Starlette keeps the `//` of a decoded `%2F` next to a `/`
if "%2F" in forwarded or "%2f" in forwarded:
readings.append(unquote(re.sub("%2[fF]", "%252F", forwarded))) # Express, aiohttp and Go keep it in its segment
if ";" in forwarded:
readings.append(re.sub("//+", "/", unquote(re.sub(";[^/]*", "", forwarded)))) # Tomcat drops `;` parameters
if "?" in checked or "#" in checked or ";" in checked:
resent = re.split("[?#]", checked, maxsplit=1)[0] # a wrapper ExApp re-sending the decoded path cuts it there
readings += [resent, re.sub("//+", "/", re.sub(";[^/]*", "", resent))] # Tomcat behind it drops `;` parameters
readings += [reading[:-1] for reading in readings if len(reading) > 1 and reading.endswith("/")] # Express, Spring
return forwarded, list(dict.fromkeys(readings))
def first_route(
routes: list[ExAppRoute], path: str, flags: int, method: str, exapp_id: str
) -> tuple[ExAppRoute | None, list[ExAppRoute]]:
"""The first route matching both path and method, as in AppAPI's PHP proxy, or None and the routes of the path.
A request without a method (a custom spoe-agent.conf without `method`) gets the first route of the path.
"""
path_routes = []
for route in routes:
try:
if not re.match(route.url, path, flags):
continue
except re.error as err:
LOGGER.error("Invalid regex %s in route for exapp %s: %s", route.url, exapp_id, err)
continue
if not method or route.accepts(method):
return route, path_routes
path_routes.append(route)
return None, path_routes
def find_route(
routes: list[ExAppRoute], readings: list[str], method: str, exapp_id: str
) -> tuple[ExAppRoute | None, list[ExAppRoute]]:
"""The route that decides the request: the first one matching the checked path, case-sensitively, and the method.
A route that another reading of the path (`exapp_paths`, the checked path first) or the path in another letter case
(Express, AppAPI's PHP proxy) matches can only raise the access level.
"""
route, path_routes = first_route(routes, readings[0], 0, method, exapp_id)
if route is None:
return None, path_routes
for reading in readings:
for flags in (0, re.IGNORECASE) if reading != readings[0] else (re.IGNORECASE,):
other, _ = first_route(routes, reading, flags, method, exapp_id)
if other is not None and other.access_level > route.access_level:
route = other
return route, path_routes
def allowed_methods(routes: list[ExAppRoute]) -> str:
"""The `Allow` header of a 405: the methods of these routes, with HEAD wherever GET is."""
methods = set().union(*(route.methods() or frozenset() for route in routes))
if "GET" in methods:
methods.add("HEAD")
return ", ".join(sorted(methods))
@SPOA_AGENT.handler("exapps_msg")
async def exapps_msg(
path: str,
headers: str,
client_ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
pass_cookie: str,
method: str = "",
) -> AckPayload:
"""An exception would kill the SPOA connection; a zero-action ACK hangs the stream until `timeout processing`."""
try:
return await _exapps_msg(path, headers, client_ip, pass_cookie, method)
except Exception:
LOGGER.exception("Unhandled error while processing request to path=%s", path)
return AckPayload().set_txn_var("agent_error", 1)
async def _exapps_msg(
path: str,
headers: str,
client_ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
pass_cookie: str,
method: str = "",
) -> AckPayload:
reply = AckPayload()
request_headers = parse_headers(headers)
client_ip_str = str(get_true_client_ip(client_ip, request_headers))
reply = reply.set_txn_var("true_client_ip", client_ip_str)
log_headers("Incoming request to ExApp: path=%s, ip=%s, headers=%s", request_headers, path, client_ip_str)
# Check if the IP is banned based on failed attempts in BLACKLIST_CACHE.
if await is_ip_banned(client_ip_str):
LOGGER.warning("IP %s is banned due to excessive failed attempts.", client_ip_str)
return reply.set_txn_var("bad_request", 1)
if DOT_SEGMENT_PATTERN.search(path):
LOGGER.warning("Rejecting path with a dot segment: %s (client_ip=%s)", path, client_ip_str)
await record_failure_unless_trusted(client_ip_str, request_headers)
return reply.set_txn_var("bad_request", 1)
match = APPID_PATTERN.search(path)
if not match:
LOGGER.error("Invalid request path, cannot find AppID: %s", path)
await record_failure_unless_trusted(client_ip_str, request_headers)
return reply.set_txn_var("not_found", 1)
exapp_id = match.group(1)
exapp_id_lower = exapp_id.lower()
target_path = path.removeprefix(f"/exapps/{exapp_id}")
# Special handling for AppAPI requests
if exapp_id == "app_api":
reply = reply.set_txn_var("target_path", target_path)
return await handle_app_api_request(target_path, request_headers, client_ip_str, reply)
exapp_route_bruteforce_protection = None
authorization_app_api = ""
exapp_record = None
if all(
key in request_headers
for key in [
"ex-app-version",
"ex-app-id",
"ex-app-host",
"ex-app-port",
"authorization-app-api",
"harp-shared-key",
]
):
# This is a direct request from AppAPI to ExApp using AppAPI PHP functions "requestToExAppXXX"
if not is_shared_key(request_headers["harp-shared-key"]):
await record_ip_failure(client_ip_str)
return reply.set_txn_var("bad_request", 1)
authorization_app_api = request_headers["authorization-app-api"]
# Use unified fetch path (cache + singleflight + K8s resolution)
try:
exapp_record = await _get_or_fetch_exapp(exapp_id_lower)
except Exception as e:
# The headers are enough for this request, but requests from users to the ExApp need the lookup.
LOGGER.warning(
"Cannot look up ExApp '%s', routing this AppAPI request by its headers: %.300s",
exapp_id,
describe_lookup_error(e),
)
exapp_record = None
if not exapp_record:
# ExApp not in Nextcloud yet (e.g. during /init) — use headers as fallback
exapp_record = ExApp(
exapp_token="",
exapp_version=request_headers["ex-app-version"],
host=request_headers["ex-app-host"],
port=int(request_headers["ex-app-port"]),
)
if not exapp_record:
try:
exapp_record = await _get_or_fetch_exapp(exapp_id_lower)
if not exapp_record:
LOGGER.error("No such ExApp enabled: %s", exapp_id)
await record_failure_unless_trusted(client_ip_str, request_headers)
return reply.set_txn_var("not_found", 1)
except ValidationError as e:
LOGGER.error("Invalid ExApp metadata from Nextcloud: %s", describe_validation_error(e))
return reply.set_txn_var("not_found", 1)
except Exception as e:
LOGGER.exception("Failed to fetch ExApp metadata from Nextcloud", exc_info=e)
return reply.set_txn_var("not_found", 1)
route_allowed = False
if authorization_app_api:
route_allowed = True # We skip routes checking for AppAPI signed requests
else:
target_path, readings = exapp_paths(target_path)
# Control characters too: Starlette's `$` also matches before a trailing newline (`/enabled%0A`).
if (
len(target_path) > MAX_FORWARDED_PATH_LENGTH
or CONTROL_CHARACTER_PATTERN.search(readings[0])
or any(reading.strip("/").lower() in LIFECYCLE_ENDPOINTS for reading in readings)
):
LOGGER.error(
"Refusing a request to an internal endpoint, with a control character or too long: %.200s", target_path
)
await record_ip_failure(client_ip_str)
return reply.set_txn_var("bad_request", 1)
route, path_routes = find_route(exapp_record.routes, readings, method, exapp_id)
if route is None and path_routes:
# Not counted toward the IP ban: link checkers, monitors and CORS preflights send such requests. So it is
# decided before the user lookup, or random credentials would get checked by Nextcloud without limit.
# not_found is also set, so that a custom /haproxy.cfg without the 405 rule answers 404.
allow = allowed_methods(path_routes)
LOGGER.info("No route of %s allows %s %s; allowed: %s", exapp_id, method, target_path, allow)
reply = reply.set_txn_var("allow", allow)
reply = reply.set_txn_var("method_not_allowed", 1)
return reply.set_txn_var("not_found", 1)
nc_user = None
if pass_cookie or "authorization" in request_headers:
# We also pass requests with "authorization" to the Nextcloud to support App Passwords and Basic Auth.
nc_user = await get_session(pass_cookie)
if not nc_user:
try:
nc_user = await nc_get_user(exapp_id_lower, request_headers)
if nc_user and pass_cookie:
await record_session(pass_cookie, nc_user)
except ValidationError as e:
LOGGER.error("Invalid user info from Nextcloud: %s", describe_validation_error(e))
return reply.set_txn_var("unauthorized", 1)
except Exception as e:
LOGGER.exception("Failed to fetch user info from Nextcloud", exc_info=e)
return reply.set_txn_var("unauthorized", 1)
if route is not None:
if route.access_level == AccessLevel.PUBLIC or (nc_user and route.access_level <= nc_user.access_level):
exapp_route_bruteforce_protection = route.str_bruteforce_protection
route_allowed = True
else:
LOGGER.error("Access denied for '%s' to %s", nc_user.user_id if nc_user else "", target_path)
await record_ip_failure(client_ip_str)
return reply.set_txn_var("forbidden", 1)
if not route_allowed:
LOGGER.error("No defined route for handling %s", target_path)
await record_ip_failure(client_ip_str)
return reply.set_txn_var("not_found", 1)
if not authorization_app_api:
user_id = nc_user.user_id if nc_user else ""
authorization_app_api = b64encode(f"{user_id}:{exapp_record.exapp_token}".encode(errors="ignore"))
if exapp_route_bruteforce_protection:
reply = reply.set_txn_var("statuses_to_trigger_bp", exapp_route_bruteforce_protection)
reply = reply.set_txn_var("backend", "ex_apps_backend_w_bruteforce")
else:
reply = reply.set_txn_var("backend", "ex_apps_backend")
if not exapp_record.resolved_host:
try:
ip_address(exapp_record.host)
exapp_record.resolved_host = exapp_record.host
except ValueError:
exapp_record.resolved_host = await resolve_ip(exapp_record.host)
if not exapp_record.resolved_host:
LOGGER.error("Cannot resolve '%s' to IP address.", exapp_record.host)
return reply.set_txn_var("not_found", 1)
LOGGER.info("Rerouting request to %s:%s with path=%s", exapp_record.resolved_host, exapp_record.port, target_path)
reply = reply.set_txn_var("target_path", target_path)
reply = reply.set_txn_var("target_ip", exapp_record.resolved_host)
reply = reply.set_txn_var("target_port", exapp_record.port)
reply = reply.set_txn_var("exapp_token", authorization_app_api)
reply = reply.set_txn_var("exapp_version", exapp_record.exapp_version)
return reply.set_txn_var("exapp_id", exapp_id)
@SPOA_AGENT.handler("exapps_response_status_msg")
async def exapps_response_status_msg(status: int, client_ip: str, statuses_to_trigger_bp: str) -> AckPayload:
"""Top-level SPOA entrypoint, see exapps_msg for why it must never raise."""
try:
return await _exapps_response_status_msg(status, client_ip, statuses_to_trigger_bp)
except Exception:
LOGGER.exception("Unhandled error while processing a response status message")
return AckPayload().set_txn_var("bp_error", 1)
async def _exapps_response_status_msg(status: int, client_ip: str, statuses_to_trigger_bp: str) -> AckPayload:
reply = AckPayload()
if not statuses_to_trigger_bp:
return reply.set_txn_var("bp_triggered", 0)
statuses = json.loads(statuses_to_trigger_bp)
if status not in statuses:
return reply.set_txn_var("bp_triggered", 0)
LOGGER.warning("Bruteforce protection(status=%s) triggered IP=%s.", status, client_ip)
await record_ip_failure(client_ip)
return reply.set_txn_var("bp_triggered", 1)
###############################################################################
# Helper functions
###############################################################################
async def handle_app_api_request(
target_path: str,
request_headers: dict[str, str],