mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
fix(objstore): serve browser assets from the consumer's own origin
web-svc-seaweedfs publishes no onion vhost by the tor-contract in its meta/services.yml, so a consumer served only on onion domains was handed an asset origin on the provider's clearnet host. A Tor-proxied browser resolves that name through nothing: BookWyrm logged 19 CSP violations for api.seaweedfs.s3.<domain>/bookwyrm/static, which also took down its theme link and logo specs because bookwyrm.js never loaded. The lookup now rebases public_domain, public_bucket, public_endpoint and public_url onto the consumer's own origin plus /objstore whenever it is onion-only and the provider is not, and exposes proxy_required, proxy_prefix, upstream_domain and upstream_scheme. A self-guarding nginx fragment routes that prefix over the loopback to the provider's own vhost, so no vhost joins the object-store network and the proxy never becomes a consumer with a bucket it does not use. The location carries the bucket and proxy_pass repeats it; a prefix-only route would serve every bucket of the store under each consumer's origin. BookWyrm additionally pins AWS_S3_URL_PROTOCOL, because upstream derives PROTOCOL from DOMAIN and calls everything but localhost https, so its asset URLs stayed https where the page is http. The vhost cross-cutting lint gains an APPLIES predicate: the route is required of an override only when the role declares an object-store engine, which takes the finding from 15 overrides down to the 5 that consume one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4c4b00a0e9
commit
40b178d9a4
12 files changed
+180
-2
No files matched your search
@@ -10,7 +10,11 @@ from ansible.plugins.lookup import LookupBase
|
||||
from utils.manager.credential_key import OVERRIDE_SECTION
|
||||
from utils.roles.applications.config import get
|
||||
from utils.roles.entity.name import entity_name
|
||||
from utils.tls_common import resolve_enabled
|
||||
from utils.tls_common import (
|
||||
collect_domains_for_app,
|
||||
is_onion_domain,
|
||||
resolve_enabled,
|
||||
)
|
||||
|
||||
OBJSTORE_ENGINES = ("seaweedfs", "minio")
|
||||
OBJSTORE_PROVIDER_ROLE = {
|
||||
@@ -23,6 +27,7 @@ OBJSTORE_DEFAULT_IMAGE = {
|
||||
"minio": "quay.io/minio/minio",
|
||||
}
|
||||
OBJSTORE_DEFAULT_REGION = "us-east-1"
|
||||
OBJSTORE_PROXY_PREFIX = "/objstore"
|
||||
|
||||
|
||||
def _is_enabled(applications: dict[str, Any], consumer_id: str, engine: str) -> bool:
|
||||
@@ -114,6 +119,10 @@ class LookupModule(LookupBase):
|
||||
"public_bucket": "",
|
||||
"public_endpoint": "",
|
||||
"public_url": "",
|
||||
"proxy_required": False,
|
||||
"proxy_prefix": "",
|
||||
"upstream_domain": "",
|
||||
"upstream_scheme": "",
|
||||
"reach_host": "127.0.0.1",
|
||||
}
|
||||
return [resolved if want == "all" else resolved.get(want, "")]
|
||||
@@ -228,7 +237,6 @@ class LookupModule(LookupBase):
|
||||
public_domain = (
|
||||
str(public_domain) if public_domain is not None else ""
|
||||
).strip()
|
||||
public_bucket = f"{public_domain}/{bucket}" if public_domain else ""
|
||||
provider_app = applications.get(provider_role) or {}
|
||||
if not isinstance(provider_app, dict):
|
||||
provider_app = {}
|
||||
@@ -237,6 +245,34 @@ class LookupModule(LookupBase):
|
||||
if resolve_enabled(provider_app, bool(vars_.get("TLS_ENABLED", True)))
|
||||
else "http"
|
||||
)
|
||||
|
||||
def _lookup(name: str, args: list[Any]) -> Any:
|
||||
return lookup_loader.get(
|
||||
name, loader=self._loader, templar=getattr(self, "_templar", None)
|
||||
).run(args, variables=vars_)[0]
|
||||
|
||||
def _consumer_is_onion_only() -> bool:
|
||||
domains_map = _lookup("domains", [])
|
||||
if consumer_id not in domains_map:
|
||||
return False
|
||||
served_on = collect_domains_for_app(
|
||||
domains_map, consumer_id, err_prefix="objstore"
|
||||
)
|
||||
return bool(served_on) and all(is_onion_domain(d) for d in served_on)
|
||||
|
||||
proxy_required = bool(
|
||||
public_domain
|
||||
and not is_onion_domain(public_domain)
|
||||
and _consumer_is_onion_only()
|
||||
)
|
||||
upstream_domain = public_domain
|
||||
upstream_scheme = scheme
|
||||
if proxy_required:
|
||||
consumer_tls = _lookup("tls", [consumer_id])
|
||||
public_domain = f"{consumer_tls['domain']}{OBJSTORE_PROXY_PREFIX}"
|
||||
scheme = consumer_tls["protocols"]["web"]
|
||||
|
||||
public_bucket = f"{public_domain}/{bucket}" if public_domain else ""
|
||||
public_endpoint = f"{scheme}://{public_domain}" if public_domain else ""
|
||||
public_url = f"{scheme}://{public_bucket}" if public_bucket else ""
|
||||
|
||||
@@ -266,6 +302,10 @@ class LookupModule(LookupBase):
|
||||
"public_bucket": public_bucket,
|
||||
"public_endpoint": public_endpoint,
|
||||
"public_url": public_url,
|
||||
"proxy_required": proxy_required,
|
||||
"proxy_prefix": OBJSTORE_PROXY_PREFIX,
|
||||
"upstream_domain": upstream_domain,
|
||||
"upstream_scheme": upstream_scheme,
|
||||
"reach_host": "127.0.0.1",
|
||||
}
|
||||
return [resolved if want == "all" else resolved.get(want, "")]
|
||||
|
||||
@@ -56,6 +56,8 @@ server
|
||||
|
||||
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/sys-svc-proxy/templates/mcp/vhost.conf.j2' %}
|
||||
|
||||
}
|
||||
@@ -26,5 +26,7 @@ server {
|
||||
error_page 500 501 502 503 504 /500.html;
|
||||
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/sys-svc-proxy/templates/mcp/vhost.conf.j2' %}
|
||||
}
|
||||
@@ -39,5 +39,7 @@ server
|
||||
|
||||
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/sys-svc-proxy/templates/mcp/vhost.conf.j2' %}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ AWS_STORAGE_BUCKET_NAME={{ lookup('objstore', application_id, 'bucket') }}
|
||||
AWS_S3_ENDPOINT_URL={{ lookup('objstore', application_id, 'url') }}
|
||||
AWS_S3_REGION_NAME={{ lookup('objstore', application_id, 'region') }}
|
||||
AWS_S3_CUSTOM_DOMAIN={{ lookup('objstore', application_id, 'public_bucket') }}
|
||||
AWS_S3_URL_PROTOCOL={{ lookup('tls', application_id, 'protocols.web') }}:
|
||||
USE_S3_FOR_EXPORTS=false
|
||||
{% else %}
|
||||
USE_S3=false
|
||||
|
||||
@@ -44,5 +44,7 @@ server
|
||||
|
||||
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/sys-svc-proxy/templates/mcp/vhost.conf.j2' %}
|
||||
}
|
||||
@@ -29,4 +29,6 @@ server
|
||||
{% endif %}
|
||||
|
||||
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
|
||||
}
|
||||
@@ -52,4 +52,6 @@ server {
|
||||
}
|
||||
|
||||
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ server
|
||||
|
||||
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
|
||||
|
||||
{% else %}
|
||||
{% include 'roles/sys-front-tls/templates/nginx_ssl_header.j2' %}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{% if lookup('objstore', application_id, 'proxy_required') | bool %}
|
||||
{% set objstore = lookup('objstore', application_id) %}
|
||||
location {{ objstore.proxy_prefix }}/{{ objstore.bucket }}/ {
|
||||
proxy_pass {{ objstore.upstream_scheme }}://127.0.0.1/{{ objstore.bucket }}/;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name {{ objstore.upstream_domain }};
|
||||
proxy_set_header Host {{ objstore.upstream_domain }};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
{% include 'roles/sys-svc-proxy/templates/headers/upstream_strips.conf.j2' %}
|
||||
}
|
||||
{% endif %}
|
||||
@@ -108,9 +108,29 @@ def scraped(role: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def consumes_objstore(role: str) -> bool:
|
||||
"""Return whether the role's own vhost has to route an object store.
|
||||
|
||||
Args:
|
||||
role: the role directory name.
|
||||
"""
|
||||
meta = PROJECT_ROOT / "roles" / role / ROLE_FILE_META_SERVICES
|
||||
if not meta.is_file():
|
||||
return False
|
||||
block = load_yaml_any(str(meta), default_if_missing={})
|
||||
if not isinstance(block, Mapping):
|
||||
return False
|
||||
for engine in ("seaweedfs", "minio"):
|
||||
entry = block.get(engine)
|
||||
if isinstance(entry, Mapping) and entry.get("enabled") is not False:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
APPLIES = {
|
||||
"roles/sys-svc-proxy/templates/mcp/vhost.conf.j2": serves_mcp,
|
||||
"roles/web-app-prometheus/templates/nginx/locations.conf.j2": scraped,
|
||||
"roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2": consumes_objstore,
|
||||
}
|
||||
|
||||
SATISFIED_BY = {
|
||||
|
||||
@@ -8,6 +8,7 @@ consumer has enabled and yields a uniform S3 connection payload.
|
||||
import importlib.util
|
||||
import unittest
|
||||
import unittest.mock as mock
|
||||
from typing import ClassVar
|
||||
from unittest.mock import patch
|
||||
|
||||
from ansible.errors import AnsibleError
|
||||
@@ -296,5 +297,95 @@ class ObjstoreLookupTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class ObjstoreSameOriginProxyTests(unittest.TestCase):
|
||||
"""Pins the rebase of the browser-facing values onto the consumer's own
|
||||
origin. web-svc-seaweedfs publishes no onion vhost (the tor-contract in its
|
||||
meta/services.yml), so a page served only on an onion reaches the provider's
|
||||
clearnet host through no route at all.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.mod = _load_module("plugins/lookup/objstore.py", "lookup_objstore")
|
||||
|
||||
APPLICATIONS: ClassVar[dict] = {
|
||||
"web-app-foo": {
|
||||
"services": {"seaweedfs": {"enabled": True, "shared": True}},
|
||||
"secrets": {"credentials": {"objstore_secret_key": "sk"}},
|
||||
},
|
||||
"web-svc-seaweedfs": {
|
||||
"services": {"seaweedfs": {"name": "seaweedfs-central"}},
|
||||
"domains": {"canonical": {"api": "api.seaweedfs.s3.example.com"}},
|
||||
},
|
||||
}
|
||||
|
||||
def _resolve(self, *, consumer_domains, consumer_web="http"):
|
||||
vars_ = {"DIR_COMPOSITIONS": "/opt/compose/", "TLS_ENABLED": True}
|
||||
lm = self.mod.LookupModule()
|
||||
lm._templar = _DummyTemplar(vars_)
|
||||
lm._loader = mock.MagicMock()
|
||||
|
||||
payloads = {
|
||||
"applications": self.APPLICATIONS,
|
||||
"domains": {"web-app-foo": consumer_domains},
|
||||
"tls": {
|
||||
"domain": consumer_domains[0],
|
||||
"protocols": {"web": consumer_web},
|
||||
},
|
||||
}
|
||||
|
||||
def fake_get(name, **_kwargs):
|
||||
return mock.MagicMock(run=lambda *_a, **_k: [payloads[name]])
|
||||
|
||||
with (
|
||||
mock.patch.object(self.mod, "lookup_loader") as loader_mock,
|
||||
patch.object(
|
||||
self.mod,
|
||||
"entity_name",
|
||||
side_effect=lambda r: r.split("-", 2)[-1],
|
||||
),
|
||||
):
|
||||
loader_mock.get.side_effect = fake_get
|
||||
return lm.run(["web-app-foo"], variables=vars_)[0]
|
||||
|
||||
def test_an_onion_only_consumer_gets_the_bucket_on_its_own_origin(self):
|
||||
out = self._resolve(consumer_domains=["foo.abcdefghij.onion"])
|
||||
|
||||
self.assertTrue(out["proxy_required"])
|
||||
self.assertEqual(out["proxy_prefix"], "/objstore")
|
||||
self.assertEqual(out["public_domain"], "foo.abcdefghij.onion/objstore")
|
||||
self.assertEqual(
|
||||
out["public_url"],
|
||||
"http://foo.abcdefghij.onion/objstore/foo",
|
||||
"the browser must dial the page's own origin, never the provider's "
|
||||
"clearnet host it cannot resolve through Tor",
|
||||
)
|
||||
self.assertEqual(
|
||||
(out["upstream_domain"], out["upstream_scheme"]),
|
||||
("api.seaweedfs.s3.example.com", "https"),
|
||||
"the nginx fragment still has to reach the provider's own vhost, so "
|
||||
"the rebase must not overwrite the upstream it proxies to",
|
||||
)
|
||||
|
||||
def test_a_clearnet_consumer_keeps_the_provider_host(self):
|
||||
out = self._resolve(consumer_domains=["foo.example.com"], consumer_web="https")
|
||||
|
||||
self.assertFalse(
|
||||
out["proxy_required"],
|
||||
"a clearnet page reaches the provider directly, so no deployment "
|
||||
"pays for a proxy hop it does not need",
|
||||
)
|
||||
self.assertEqual(out["public_domain"], "api.seaweedfs.s3.example.com")
|
||||
self.assertEqual(out["public_url"], "https://api.seaweedfs.s3.example.com/foo")
|
||||
|
||||
def test_a_consumer_on_both_families_keeps_the_provider_host(self):
|
||||
out = self._resolve(
|
||||
consumer_domains=["foo.example.com", "foo.abcdefghij.onion"],
|
||||
)
|
||||
|
||||
self.assertFalse(out["proxy_required"])
|
||||
self.assertEqual(out["public_domain"], "api.seaweedfs.s3.example.com")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user