Baue deinen eigenen Envoy-Http-Filter mit Proxy-Wasm

24.08.2022Raffael Schneider
Cloud Proxy Envoy WebAssembly Rust http

Wir haben in einem älteren TechUp über Netzwerk-Proxies im Allgemeinen geschrieben, und sind dabei genauer auf den sogenannten Reverse-Proxy eingegangen. Dabei lag das Augenmerk darauf, zu verstehen, was genau ein solcher Reverse-Proxy tut, und wie man einen solchen konfiguriert. Bei diesem TechUp kam der bekannte HAProxy zum Einsatz. Wir haben dabei den HAProxy containerfähig gemacht und eine einfache Forwarding-Konfiguration geschrieben, welche wir direkt in das Container-Image mit eingepackt haben.

Obwohl HAProxy sehr verbreitet ist, gibt es im Cloud-Native-Umfeld weitere Konkurrenzprodukte, welche das Proxying etwas anders aufbereiten. Ganz prominent ist der Envoy Proxy, ein Open-Source-Projekt, welches gleichzeitig ein CNCF Graduated-Projekt ist. Envoy gilt als Service-, wie auch als Edge-Proxy, und bezeichnet seine geografische Platzierung in der Cloud-Infrastruktur. In beiden Fällen fungiert der Envoy als Reverse-Proxy.

Als Service-Proxy stellt Envoy die Connectivity zwischen Services zur Verfügung, was auch eine East-West-Verbindung genannt wird. Den klassischen Ingress von aussen hin zu einem Service in der Zielinfrastruktur, nennt man auch North-South-Verbindung. Hierbei steht der Envoy näher beim Ingress-Eintritt, und ist somit näher an der Edge, darum Edge-Proxy. Das sind tendenziell neurere Begriffe, da diese erst durch die Requirements einer Cloud-Landschaft ersichtlich wurden.

Im heutigen TechUp nehmen wir Envoy als Proxy-Grundlage und schauen, wie wir mit Wasm-Modulen einen eigenen HTTP-Filter schreiben können und somit die Funktionalität eines Reverse-Proxies wie Envoy erweitern können.

HTTP-Filter

Als Reverse-Proxy steht der Envoy zwischen dem Web und dem Backend. Damit ist der Reverse-Proxy die Netzwerk-Komponente, welche den eingehenden Traffic beeinflussen kann. Konkret heisst das, dass ein Reverse-Proxy wie Envoy den HTTP-Traffic manipulieren kann. Dies ermöglicht es, gewisse Netzwerk- und HTTP-spezifische Logik einzubauen, welche noch vor dem Backend-System in Kraft treten.

Figure: HTTP transformation / mutation

Nehmen wir einen HTTP-Request als Beispiel zur Hand. Einen solchen Request kann man in den Developer-Tools von Chrome (Auf MacOS einfach: CMD + ALT + i) oder von anderen geläufigen Browsern ersichtlich machen. Diesen Beispiel-Request unten habe ich beim Zugriff auf die /home/techup-Unterseite herauskopiert.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
GET /solr/b-nova-techhub HTTP/1.1

Accept: application/json, text/plain, */*
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,de;q=0.7
Connection: keep-alive
Host: solr.b-nova.com
Origin: https://b-nova.com
Referer: https://b-nova.com/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36
sec-ch-ua: ".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"

Ein HTTP-Filter ändert den Zustand eines HTTP-Requests, oder auch einer HTTP-Response. In diesem Fall heisst das, dass wir einen Header aus dem Request entfernen, modifizieren, oder gar einen neuen Header hinzufügen. Das nennt sich auch Mutation.

Jetzt nehmen wir ein paar Mutationen vor. Als Kennzeichnung nutze ich hier eine Diff-angelehnte Syntax:

  • + : Fügt einen Header hinzu
  • -: Entfernt einen Header
  • !: Modifiziert einen bestehenden Header

Die “diff” sieht bei uns nun so aus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  GET /solr/b-nova-techhub HTTP/1.1

  Accept: application/json, text/plain, */*
  Accept-Encoding: gzip, deflate, br
  Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,de;q=0.7
  Connection: keep-alive
  Host: solr.b-nova.com
  Origin: https://b-nova.com
! Referer: https://www.b-nova.com/
- Sec-Fetch-Dest: empty
- Sec-Fetch-Mode: cors
- Sec-Fetch-Site: same-site
+ X-My-New-Header: awesome-value
  User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36
- sec-ch-ua: ".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"
- sec-ch-ua-mobile: ?0
- sec-ch-ua-platform: "macOS"

Wenn wir annehmen, dass für jede Mutation ein HTTP-Filter bestehen würde, welcher diese Modifikationen vornehmen kann, dann wäre das Resultat aller HTTP-Filter-Mutationen wie folgt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
GET /solr/b-nova-techhub HTTP/1.1

Accept: application/json, text/plain, */*
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,de;q=0.7
Connection: keep-alive
Host: solr.b-nova.com
Origin: https://b-nova.com
Referer: https://www.b-nova.com/
X-My-New-Header: awesome-value
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36

Die geordnete Abfolge von HTTP-Filtern, auch Kaskadierung genannt, nennt man in der Summe eine HTTP-Filter-Chain.

Figure: HTTP-Filter-Chain

Die Proxy-Wasm-ABI

Eine Application Binary Interface, kurz ABI, ist die Schnittstelle, welche den Datenaustausch zwischen dem Host-System und dem WebAssembly-Modul definiert. In unserem Fall ist das Host-System der Envoy Proxy.

Klassischerweise erlaubt dei ABI die Kommunikation zwischen zwei Applikationen. Die ABI fungiert als die im Vorfeld definierte Schnittstelle, sodass man auf diese ABI hin die Schnittstelle ansprechen und programmieren kann.

Figure: Proxy-Wasm-ABI

Für das Proxy-Wasm-ABI gibt es eigens für Rust ein SDK, welches die ganze ABI komfortabel in Rust bereitstellt. Am besten versucht man sich die ABI über die Codebase des SDKs anzueignen. Aus diesem Grund wagen wir ein paar Einblicke in den Sourcecode, um die ABI besser verstehen zu können. Zu allererst schauen wir ins traits.rs-Sourcefile, worin wir unterschiedliche Hooks einsehen können.

1
2
3
4
5
6
7
// proxy-wasm-rust-sdk/src/traits.rs
pub trait HttpContext: Context {
    fn on_http_request_headers(&mut self, _num_headers: usize, _end_of_stream: bool) -> Action {
        Action::Continue
    }
		...
}

fn on_http_request_headers() ist ein Hook, mit dem, wie der Name schon sagt, aufgerufen wird, sobald HTTP-Request-Headers in die Filter-Chain von Envoy eintreffen. Dieser wird in der ABI so definiert, dass mit der Expression Action::Continue nichts gemacht wird. Sofern wir diese Funktion neu implementieren und überschreiben, können wir beim Aufruf dieses Hooks in der Filter-Chain eigene Filterlogik verarbeiten, welche die Request-Header entsprechend neu aufbereitet.

Was wir aber hierfür brauchen ist eine Möglichkeit, die Headers zur Laufzeit aus der Filter-Chain auszulesen, und nach Bedarf wieder neu zu schreiben. Hierfür finden wir in der gleichen ABI-Definition weiter unten folgende zwei Funktionen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
pub trait HttpContext: Context {
		...
    fn get_http_request_headers(&self) -> Vec<(String, String)> {
        hostcalls::get_map(MapType::HttpRequestHeaders).unwrap()
    }
		...
    fn set_http_request_headers(&self, headers: Vec<(&str, &str)>) {
        hostcalls::set_map(MapType::HttpRequestHeaders, headers).unwrap()
    }
    ...
}

fn get_http_request_headers() und fn set_http_request_headers() sind beides Funktionen, die aus dem Host-Call eine Map von Header-Feldern einerseits auslesen (get), und schreiben (set).

Somit haben wir alle Bausteine aus der ABI zusammen, um selber die Header-Felder zur Laufzeit bei einem Request-Aufruf auszulesen, die eigene, gewünschte Logik abzuarbeiten, und den Endzustand wieder zurück in die Header-Map zu schreiben, mit der Hoffnung, dass im Backend die gewünschte Mutation an den Headerdaten ankommt. Soweit, so gut.

Wie geht das Mutieren eines Request-Headers?

So, wie fahren wir nun fort, um unser eigenes HTTP-Filter-Modul mit WASM zu schreiben? Wir setzen zuerst mal ein neues Projekt mit Cargo auf. Wir nennen das Projekt mal ganz einfach envoy-request-header-mutator, und geben Cargo noch den lib-Flag mit, sodass dieser weiss, dass wir ein Modul und kein Standalone-Binary kompilieren möchten:

1
2
$ cargo new envoy-request-header-mutator --lib
     Created binary (application) `envoy-request-header-mutator` package

Unter /src sollte ein lib.rs file liegen, in dem zur Zeit noch Fillercode (2 + 2 = 4) steht. Wir werden hier gleich unsere Filterlogik hineinschreiben:

 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
use proxy_wasm::traits::*;
use proxy_wasm::types::*;
use serde::{Deserialize};
use regex::Regex;

proxy_wasm::main! {{
    proxy_wasm::set_log_level(LogLevel::Trace);
    proxy_wasm::set_root_context(|_| -> Box<dyn RootContext> {
        Box::new(HttpRequestHeaderFilterRoot {
            config_stringvalue: String::new(),
        })
    });
}}

struct CustomFilter {
    config_stringvalue: String,
}

impl Context for CustomFilter {}

#[derive(Deserialize)]
#[serde(tag = "type")]
enum Operation {
    Add { header: String, value: String},
    Modify { header: String, regex: String, replace_with: String},
    Replace { header: String, replace_with: String },
    Remove { header: String },
}

impl HttpContext for CustomFilter {
    fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action {
        match serde_json::from_str(self.config_stringvalue.as_str()) {
            Err(e) => log::error!("Config deserialization failed. Reason: {}", e),
            Ok(operation) =>
                match &operation {
                    Operation::Add { header, value } => {
                        self.add_http_request_header(header, value);
                        log::info!("Add succeeded. Added header: {}", header);
                    }
                    Operation::Modify { header, regex, replace_with} => {
                        if let Some(header_field) = self.get_http_request_header(header) {
                            let replaced_header_field = Regex::new(regex).unwrap().replace_all(&header_field, replace_with);
                            self.set_http_request_header(header, Some(&replaced_header_field));
                            log::info!("Modify succeeded. Modified header: {}", header);
                        }
                    }
                    Operation::Replace { header, replace_with} => {
                        if let Some(_header_field) = self.get_http_request_header(header) {
                            self.set_http_request_header(header, Some(replace_with));
                            log::info!("Replace succeeded. Replaced header: {}", header);
                        }
                    }
                    Operation::Remove { header } => {
                        if let Some(_header_field) = self.get_http_request_header(header) {
                            self.set_http_request_header(header, None);
                            log::info!("Remove succeeded. Removed header: {}", header);
                        }
                    }
                }
        };
        Action::Continue
    }
}

struct CustomFilterRoot {
    config_stringvalue: String,
}

impl Context for CustomFilterRoot {}

impl RootContext for CustomFilterRoot {
    fn on_configure(&mut self, _: usize) -> bool {
        if let Some(config_bytes) = self.get_plugin_configuration() {
            self.config_stringvalue = String::from_utf8(config_bytes).unwrap()
        }
        true
    }

    fn create_http_context(&self, _: u32) -> Option<Box<dyn HttpContext>> {
        Some(Box::new(CustomFilter {
            config_stringvalue: self.config_stringvalue.clone(),
        }))
    }

    fn get_type(&self) -> Option<ContextType> {
        Some(ContextType::HttpContext)
    }
}

Jetzt bauen wir unser WASM-Modul mit wasm-pack. Zuerst sollte man wasm-pack installieren. Das kann man wahlweise mit einem Package-Manager der Wahl (auf macOS bevorzuge ich brew), mit cargo install oder am schnellsten mit dem offiziellen Installer-Shell-Script von der offiziellen wasm-pack-Website.

1
2
3
$ curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
info: downloading wasm-pack
info: successfully installed wasm-pack to `/Users/rschneider/.asdf/shims/wasm-pack`

Falls Du nicht selber den Code schreiben möchtest, kannst du gerne auch das entsprechende GitHub-Repo von mir auschecken und von dessen Root-Verzeichnis weitermachen.

1
$ git clone https://github.com/raffaelschneider/envoy-request-header-mutator.git
1
$ wasm-pack build --release

Hierbei landet das Build-Artefakt unter pgk/, man kann aber mit dem --out-dir wasm/target/dein-zielverzeichnis ein eigenes Zielverzeichnis definieren.

Hier noch die envoy/envoy.yaml-Konfigurationsdatei, welche den Envoy Proxy konfiguriert:

 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
static_resources:
  listeners:
  - name: main
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 8000
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: web_service
          http_filters:
          - name: envoy.filters.http.wasm
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
              config:
                name: envoy-request-header-mutator
                root_id: envoy-request-header-mutator
                configuration:
                  "@type": "type.googleapis.com/google.protobuf.StringValue"
                  value: |
                    {
                      "type": "Add",
                      "header": "X-My-New-Header",
                      "value": "awesome-value"
                    }                    
                vm_config:
                  vm_id: vm.sentinel.envoy-request-header-mutator
                  runtime: envoy.wasm.runtime.v8
                  code:
                    local:
                      filename: /{PATH_TO_WASM_MODULE}/envoy-request-header-mutator.wasm
                  allow_precompiled: true                   
          - name: envoy.filters.http.router                  
  clusters:
  - name: web_service
    connect_timeout: 0.25s
    type: STRICT_DNS
    lb_policy: round_robin
    load_assignment:
      cluster_name: web_service
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: 127.0.0.1
                port_value: 5678
admin:
  access_log_path: "/dev/null"
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 8001

So, jetzt wollen wir zuerst noch unser Backend mit einer einfachen Echo-Applikation emulieren. Es gibt zahlreiche Applikationen, die einfaches HTTP-Echoing bereitstellen. Hier habe ich mich für HashiCorp’s http-echo entschieden. Man kann das Projekt über das Repository mit den Go-Build-Tools selber bauen, oder einfach das entsprechende Docker-Image nutzen.

Sofern der Docker-Daemon läuft, kann man das http-echo-Image wie folgt als Container instanziieren:

1
$ docker run -p 5678:5678 hashicorp/http-echo -text="This is a HTTP Response!"

Das Port-Mapping ist 1:1, das heisst, dass der Port 5678, welcher von der Echo-Applikation als Listener läuft, auch nach aussen über den gleichen Port exponiert. Somit kann man das Echo wie folgt testen:

1
2
$ curl localhost:5678
This is a HTTP Response!

Das war nur ein Test ohne einen Envoy als Reverse-Proxy dazwischengeschaltet zu haben. Nun fügen wir diesen aber hinzu, um die ganze Strecke zu testen. Envoy starten mit dem envoy-Befehl, wir geben als -c Flag die envoy.yaml-Konfigurationsdatei mit.

1
2
3
$ envoy -c envoy/envoy.yaml
[2022-08-03 14:27:47.031][14448][info][main] [external/envoy/source/server/server.cc:330] initializing epoch 0 (base id=0, hot restart version=11.104)
...

So sieht die jetzige Konstellation mit einem Filterfähigen Envoy-Reverse-Proxy zwischen Client und Backend aus:

Figure: Konfiguration mit Envoy-Reverse-Proxy

Jetzt möchten wir einen neuen Request gegen Envoy absetzen und schauen, ob der Echo-Service den Request-Header mit ausliefert. HashiCorp’s http-echo ist leider nicht fähig den Request-Header in den Response-Body zu schreiben, aber hierfür gibt Alternativen wie zum Beispiel den ealenn/Echo-Server.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ curl -i localhost:8000
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 1264
etag: W/"4f0-vrmKg5I9fOw8rz10EbdMEUV0tf4"
date: 'Wed, 03 Aug 2022 12:29:36 GMT'
x-envoy-upstream-service-time: 23
server: envoy

{"host":{"hostname":"localhost","ip":"::ffff:10.0.2.100","ips":[]},"http":{"method":"GET","baseUrl":"","originalUrl":"/","protocol":"http"},"request":{"params":{"0":"/"},"query":{},"cookies":{},"body":{},"headers":{"host":"localhost:8000","user-agent":"curl/7.82.0","accept":"*/*","x-forwarded-proto":"http","x-request-id":"b793c73e-57a8-45fd-bfb6-412e7639b653","x-my-new-header":"awesome value","x-envoy-expected-rq-timeout-ms":"15000"}},"environment":{"PATH":"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","TERM":"xterm",...}}%   

Hurra!! Geschafft. Das WASM-Modul fungiert offensichtlich als HTTP-Filter für Envoy und reichert den hereinkommenden Request mit einem neuen Request-Header X-My-New-Header an.

Fazit

Mit WASM-fähigen Extensions setzt Envoy als CNCF-Proxy-Lösung auf eine zukunftsfähige Technologie und wagt somit den Schritt in eine Web-Native Richtung. Wie immer hoffe ich, du konntest in diesem TechUp etwas neues lernen. Falls du möchtest, kannst du dir in unserem TechHub weitere spannende TechUp‘s anschauen. Stay tuned für weitere spannende Beiträge zum Thema! 😁

Proxy-Wasm | GitHub

WebAssembly for Proxies (Rust SDK) | GitHub

WASM | Envoy Proxy Docs