From 978507a980c03d376b4fb0176a97c7d6459fc044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco?= Date: Mon, 22 Jul 2024 20:14:59 +0200 Subject: [PATCH 01/13] Add TLS Cipher header --- .github/workflows/main.yml | 8 ++--- .traefik.yml | 9 +++--- demo.go | 66 -------------------------------------- demo_test.go | 49 ---------------------------- go.mod | 4 +-- go.sum | 0 plugin.go | 57 ++++++++++++++++++++++++++++++++ plugin_test.go | 60 ++++++++++++++++++++++++++++++++++ 8 files changed, 127 insertions(+), 126 deletions(-) delete mode 100644 demo.go delete mode 100644 demo_test.go create mode 100644 go.sum create mode 100644 plugin.go create mode 100644 plugin_test.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 025a3d1..c55a651 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: name: Main Process runs-on: ubuntu-latest env: - GO_VERSION: 1.19 + GO_VERSION: 1.22 GOLANGCI_LINT_VERSION: v1.50.0 YAEGI_VERSION: v0.14.2 CGO_ENABLED: 0 @@ -24,20 +24,20 @@ jobs: # https://github.com/marketplace/actions/setup-go-environment - name: Set up Go ${{ env.GO_VERSION }} - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} # https://github.com/marketplace/actions/checkout - name: Check out code - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: path: go/src/github.com/${{ github.repository }} fetch-depth: 0 # https://github.com/marketplace/actions/cache - name: Cache Go modules - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ github.workspace }}/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} diff --git a/.traefik.yml b/.traefik.yml index 57fb70e..6ab3575 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -1,12 +1,11 @@ -displayName: Demo Plugin +displayName: TLS headers type: middleware iconPath: .assets/icon.png -import: github.com/traefik/plugindemo +import: github.com/RiskIdent/traefik-tls-headers-plugin -summary: '[Demo] Add Request Header' +summary: 'Add TLS information to request headers' testData: Headers: - X-Demo: test - X-URL: '{{URL}}' + X-TLS-Cipher: TLS_AES_128_GCM_SHA256 diff --git a/demo.go b/demo.go deleted file mode 100644 index 88d8c18..0000000 --- a/demo.go +++ /dev/null @@ -1,66 +0,0 @@ -// Package plugindemo a demo plugin. -package plugindemo - -import ( - "bytes" - "context" - "fmt" - "net/http" - "text/template" -) - -// Config the plugin configuration. -type Config struct { - Headers map[string]string `json:"headers,omitempty"` -} - -// CreateConfig creates the default plugin configuration. -func CreateConfig() *Config { - return &Config{ - Headers: make(map[string]string), - } -} - -// Demo a Demo plugin. -type Demo struct { - next http.Handler - headers map[string]string - name string - template *template.Template -} - -// New created a new Demo plugin. -func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { - if len(config.Headers) == 0 { - return nil, fmt.Errorf("headers cannot be empty") - } - - return &Demo{ - headers: config.Headers, - next: next, - name: name, - template: template.New("demo").Delims("[[", "]]"), - }, nil -} - -func (a *Demo) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - for key, value := range a.headers { - tmpl, err := a.template.Parse(value) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - writer := &bytes.Buffer{} - - err = tmpl.Execute(writer, req) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - req.Header.Set(key, writer.String()) - } - - a.next.ServeHTTP(rw, req) -} diff --git a/demo_test.go b/demo_test.go deleted file mode 100644 index dd0edcf..0000000 --- a/demo_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package plugindemo_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/traefik/plugindemo" -) - -func TestDemo(t *testing.T) { - cfg := plugindemo.CreateConfig() - cfg.Headers["X-Host"] = "[[.Host]]" - cfg.Headers["X-Method"] = "[[.Method]]" - cfg.Headers["X-URL"] = "[[.URL]]" - cfg.Headers["X-URL"] = "[[.URL]]" - cfg.Headers["X-Demo"] = "test" - - ctx := context.Background() - next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}) - - handler, err := plugindemo.New(ctx, next, cfg, "demo-plugin") - if err != nil { - t.Fatal(err) - } - - recorder := httptest.NewRecorder() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) - if err != nil { - t.Fatal(err) - } - - handler.ServeHTTP(recorder, req) - - assertHeader(t, req, "X-Host", "localhost") - assertHeader(t, req, "X-URL", "http://localhost") - assertHeader(t, req, "X-Method", "GET") - assertHeader(t, req, "X-Demo", "test") -} - -func assertHeader(t *testing.T, req *http.Request, key, expected string) { - t.Helper() - - if req.Header.Get(key) != expected { - t.Errorf("invalid header value: %s", req.Header.Get(key)) - } -} diff --git a/go.mod b/go.mod index 38181bb..ce1ba77 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/traefik/plugindemo +module github.com/RiskIdent/traefik-tls-headers-plugin -go 1.19 +go 1.22.5 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..72171b4 --- /dev/null +++ b/plugin.go @@ -0,0 +1,57 @@ +// Package plugin contains the Traefik plugin for adding headers based on the +// TLS information +package plugin + +import ( + "context" + "crypto/tls" + "errors" + "net/http" +) + +var errMissingHeaderConfig = errors.New("missing header config: must set headers.cipher") + +// Config the plugin configuration. +type Config struct { + Headers ConfigHeaders `json:"headers,omitempty"` +} + +// ConfigHeaders defines the headers to use for the different values. +type ConfigHeaders struct { + Cipher string `json:"port,omitempty"` +} + +// CreateConfig creates the default plugin configuration. +func CreateConfig() *Config { + return &Config{ + Headers: ConfigHeaders{}, + } +} + +// TLSHeadersPlugin is the main handler model for this Traefik plugin. +type TLSHeadersPlugin struct { + next http.Handler + headers ConfigHeaders + name string +} + +// New created a new TLSHeadersPlugin. +func New(_ context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { + if config.Headers == (ConfigHeaders{}) { + return nil, errMissingHeaderConfig + } + + return &TLSHeadersPlugin{ + headers: config.Headers, + next: next, + name: name, + }, nil +} + +func (a *TLSHeadersPlugin) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if a.headers.Cipher != "" && req.TLS != nil { + req.Header.Set(a.headers.Cipher, tls.CipherSuiteName(req.TLS.CipherSuite)) + } + + a.next.ServeHTTP(rw, req) +} diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 0000000..28881b4 --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,60 @@ +package plugin_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + plugin "github.com/RiskIdent/traefik-tls-headers-plugin" +) + +func TestInvalidConfig(t *testing.T) { + cfg := plugin.CreateConfig() + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + _, err := plugin.New(context.Background(), next, cfg, "traefik-tls-headers-plugin") + if err == nil { + t.Fatal("expected error") + } +} + +func TestTLSCipher(t *testing.T) { + cfg := plugin.CreateConfig() + cfg.Headers.Cipher = "X-TLS-Cipher" + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + assertHeader(t, r.Header, "X-TLS-Cipher", "TLS_AES_128_GCM_SHA256") + }) + handler, err := plugin.New(context.Background(), next, cfg, "traefik-tls-headers-plugin") + if err != nil { + t.Fatal(err) + } + + server := httptest.NewTLSServer(handler) + defer server.Close() + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + if err != nil { + t.Fatal(err) + } + + client := server.Client() + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + t.Fatal(err) + } + }(resp.Body) +} + +func assertHeader(t *testing.T, header http.Header, key, expected string) { + t.Helper() + + if header.Get(key) != expected { + t.Errorf("invalid header value\nwant: %s=%q\ngot: %s=%q", key, expected, key, header.Get(key)) + } +} From af030f189590bf5338384a3ea0a0b0fd9ed9c199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco?= Date: Tue, 23 Jul 2024 08:36:52 +0200 Subject: [PATCH 02/13] Update go version and actions in github action --- .github/workflows/go-cross.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go-cross.yml b/.github/workflows/go-cross.yml index 6e2b2e4..4bc830f 100644 --- a/.github/workflows/go-cross.yml +++ b/.github/workflows/go-cross.yml @@ -11,23 +11,23 @@ jobs: strategy: matrix: - go-version: [ 1.19, 1.x ] + go-version: [ 1.22 ] os: [ubuntu-latest, macos-latest, windows-latest] steps: # https://github.com/marketplace/actions/setup-go-environment - name: Set up Go ${{ matrix.go-version }} - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} # https://github.com/marketplace/actions/checkout - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 # https://github.com/marketplace/actions/cache - name: Cache Go modules - uses: actions/cache@v3 + uses: actions/cache@v4 with: # In order: # * Module download cache From 10a94ae61afd0ea8de7b58740891f373018d4678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco?= Date: Tue, 23 Jul 2024 08:40:06 +0200 Subject: [PATCH 03/13] Update golangci-lint version --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c55a651..61a8f56 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest env: GO_VERSION: 1.22 - GOLANGCI_LINT_VERSION: v1.50.0 + GOLANGCI_LINT_VERSION: v1.59.1 YAEGI_VERSION: v0.14.2 CGO_ENABLED: 0 defaults: From af8f2d5512feb2f90c61a643202482c338061383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco?= Date: Tue, 23 Jul 2024 08:45:00 +0200 Subject: [PATCH 04/13] Switch tests to the same package as plugin so it can be used directly --- .github/workflows/main.yml | 2 +- plugin_test.go | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 61a8f56..6c8b00f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: env: GO_VERSION: 1.22 GOLANGCI_LINT_VERSION: v1.59.1 - YAEGI_VERSION: v0.14.2 + YAEGI_VERSION: v0.16.1 CGO_ENABLED: 0 defaults: run: diff --git a/plugin_test.go b/plugin_test.go index 28881b4..8527125 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -1,4 +1,4 @@ -package plugin_test +package plugin import ( "context" @@ -6,26 +6,24 @@ import ( "net/http" "net/http/httptest" "testing" - - plugin "github.com/RiskIdent/traefik-tls-headers-plugin" ) func TestInvalidConfig(t *testing.T) { - cfg := plugin.CreateConfig() + cfg := CreateConfig() next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) - _, err := plugin.New(context.Background(), next, cfg, "traefik-tls-headers-plugin") + _, err := New(context.Background(), next, cfg, "traefik-tls-headers-plugin") if err == nil { t.Fatal("expected error") } } func TestTLSCipher(t *testing.T) { - cfg := plugin.CreateConfig() + cfg := CreateConfig() cfg.Headers.Cipher = "X-TLS-Cipher" next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { assertHeader(t, r.Header, "X-TLS-Cipher", "TLS_AES_128_GCM_SHA256") }) - handler, err := plugin.New(context.Background(), next, cfg, "traefik-tls-headers-plugin") + handler, err := New(context.Background(), next, cfg, "traefik-tls-headers-plugin") if err != nil { t.Fatal(err) } From 1d4031e531772c53c2381fa3a01912803b20cf09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco?= Date: Tue, 23 Jul 2024 08:52:58 +0200 Subject: [PATCH 05/13] Add codeowners --- .github/CODEOWNERS | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..de22884 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2024 Risk.Ident GmbH +# +# SPDX-License-Identifier: CC0-1.0 + +* @RiskIdent/platform From 46225228251faf52072226325fcb03b06653ee2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco?= Date: Tue, 23 Jul 2024 08:57:49 +0200 Subject: [PATCH 06/13] Correct cipher tag --- plugin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.go b/plugin.go index 72171b4..d4c6a1a 100644 --- a/plugin.go +++ b/plugin.go @@ -18,7 +18,7 @@ type Config struct { // ConfigHeaders defines the headers to use for the different values. type ConfigHeaders struct { - Cipher string `json:"port,omitempty"` + Cipher string `json:"cipher,omitempty"` } // CreateConfig creates the default plugin configuration. From f86ee7eed4788123ece078b96d7532c847d94043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco?= Date: Tue, 23 Jul 2024 11:51:34 +0200 Subject: [PATCH 07/13] Add icon (free asset downloaded from https://www.flaticon.com/de/kostenloses-icon/tls-protokoll_4896619) --- .assets/icon.png | Bin 24497 -> 19374 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.assets/icon.png b/.assets/icon.png index 1a3770b0f9830a8557e845264352ae17126f172d..e56f3bfc0091975a0b3d8f306d583c7f99aea33c 100644 GIT binary patch literal 19374 zcmeHvcT`i^_ir2&#s>C=fE^oR0~CayG!-c}5D=nbK}t{vMgmDtMo|#3qap-BK}Em< z1VV}EC=N&o7=l2E)HFzdB!txaPSD`|X68G;_x^k9_ug8w*39JIbI#uT?E2aJa@Ez@ zVd|s>lVC8|)LlDm_rYMlfdBjj_NylNm+<@-DGcTqw##<=f%887bgg{v6Di`sTf1AF zjD__L*8aan1!@dytNelSrD<~W(ibhb&7y3XB1ckluZ!hYRjQF{F+q}?yJ9i z3JVJJ&vqet(l~TCZCFTW-3I01uDY#R2ioCU)AzMzrd4@lsc2Am4ee9ew z$Gy{V3G7RCe~!7^m6f!S9-Xu+a}HivNt9PBrH?hD((fHuoZc;^qA z>1fVaUWac%5CUPYoAB1WC>qpt=jY6#OZszFu0w7j7*pHGnb?n3gy>fDl(3&u(b_hOBRn? zviqJ@#hxoyS{ej4zl;)gf7 zDGqdY7J+AfZC(dvxNm+=hM#msjG8j3YZH^P0SZ z!D!j2n#O zb;cQ=E8&p>P=Rk)v^;_Zc#jSiuzkXNX46j`ZLGZUUAY+TM!w^X2Gel zYJ@Uf$1L0<5s?G7#0fJDiod=&RWeRP)3$O$few~sgY0;BiFXPvyT2~Ol}(!YT``vB zy_OI8&q_kzimlqZJrWQ`O|(x~K{5l?^)Ws!!OIBM*G$nn`}=erI3Q#(4iA?;B%Y-+Rw}pqGUT{uyf4YGbaj3jHyz^ddc{XgTD*u-J)6#*OC);r0FY<0+r0guZn^(|my`&}Fw&FL*n@%M}$oh$*jxWo+ zU4AJ3V`THoqZF&ED!dLnY*ivcpBr$yTt??B8R%hM%5ZBiUnxRB2MyeKZ1W%1wk)g+ zBZ?i~>!y5_xvpoh+7}yNzu(2O2P5X`=_T4XKHG1I?k}J<32iQtF8?s8UEWoQBX&&u zACH~IN2!5|ZbSI055|zl>ERZNf)6S4tiBv>z#9ILJ?FBHfqs9+3puT5UzY5pe`Y&Y zOh4{EwB~_^_Lem1$dI!*c}!xoOqIlh>_$;XawGPT$$q6xqI9JCcSh18K8ooUE;9UKEVwdP(bVWyG2iLI=3NH31{7X>f`c*_MIyYGG$fTj*0I<{GRQv3%1lPa z>Z3g*tJO4uU*cHq_a#mTia6!E9!w0t?5soQaEJ1!oVgR4XR3_hqTId(F z^jWzjJah4#yvPqZUc_AKxNT77Hw%7gmk(YMCN9Xs*3i#)#q)lj>4J*Z(n%=15d*B! zL&xql3h_gN`-G@6H#-+vz` z3c3<3NMxlbkHkIBR{mi8v5Kxy0$(VGiR3VE66&gc9u@ey{&Gmc=ki+Ukh~5m0#36F z`8H}C#cC~ c=}`12lrQqd&O2@$>Wlpvb=74A?b2AB?=>W=6yY(5s}hwtx!UwI%_ zz{XVUPYZd#NLjBudn6qsf>M~pON-Wy9WE}GPlzfPm+{#rZ)M4xekfXc7PnwEhUAm| zx)**gE0OvR-?g+(+=Nwb`b8OZp*F?BLB_b3Y8l}M_i~LxG5#j1rgbD%Iyh@&jeFB- z$XHTS7Bq~L^)pkk!ya^7BwP8zFa1d}DIry>D82>fyKgw4<`(yocaJ=C!B**od|I3v zA?8(hVuY-Y1PfjfM`Sou%IUt`&~@{N7LHzy>XuFfalSkQdkm1Taj67IeoEXDkXMX{ z#P~eiudkZOLYhmW;}1ojW$B%FkkTmfyi*{kwJ^rKn4>HU-wnrutl%b4c(S^_6V7cP& zGE)lR&?d}_XMx2%cX?879vV_GpqvsTEN1QB6zOx&n;KI}m(w4JavGzVDPa<5-btpG znt$81S8jX?TN=)4ewAU-d(XFRrpvY)GU_kf6szGMPH>g#o<}b{6d|c{Pp?ci!%rZ$ zB1>u%D(}#9dkMDmAGV!+ z41}FFDWJBv85d%RGjuREqYA1b-2ZZKZHlfX52I^|$ya$D5Gin3)kY}kmIe3V4zf_u z`yA)oQoOdIKl+we+2N~NFJ_Hh@PkLhc1*R1-DrsEvs>?1zX&bqNh&dGKW~%d{RyR5 zKGf)TYqf2%N2Ocb>cK^Kc8@XjraPFKomjThJdD%5!s$VGYUI!^Z_J~Hu%zqoELo@p z#lEC$hK>t+Ow&yEH)cA;>Eoxz>zd`h#Q)0jbgY7=TMI8pk`!TnyPu7DiTl!;VBCI1 zYPo)Ny7|zjrSv%C@~1i(x$Fgw*N7T^(Gs1w+ryUlgKE!24J?!NCL@7huO!&)4P!Ta zFgo<|twJd#(_;1|p|Hz);WOWAX{@TkA3G59B>yMtuYj}r8dN}aqr6)AdiqlPqEo5i z_6ECa8pH0i-JWzqxO2Jw?S`LXZt}sfmz(*2dCEFJ%Y%rdw`u5XOVfFh$$qfA7H@Zb zCF{xVZ7ZMrBj!U^{emi9__tJ95 zyR4fxpUC|GdaUGNp8JGzfW$Vd$!R|xuh3Bp?cc>7{vPI3a(Zg2XeBF>ExN94I?Sw1 z$slakSF#DpT}juM(G%~al3$=?6Q@mJN8Mw=y#C9&!ZIl*oA-HB-DgFshB7gJTZ7^d zTT;E7NPz9Vu6^aI_RBj+#%QQlDRL~Q5lxnPn;rZ83X6H8>g@#$I#8pm5|b?1s|G>R z9VA@E=d_u6N7m5GM;*S(Bh9Afq1(eNod{Nz()*8xgpDrTbv10O7ee`oC#^0~D|c%B zZmZS2n;1N%%yyz0MkK4j(KsHrZdDtafhAdll62Ns;yu{IqcYz>8CTfQ?=|Z0R6b&_ zLVzE?=l;g!(yxbV?rpF^p8)CI6Q=PRI&M^;E6m@zk+9z#FSvs>tL=l!M0a&J`?(4Q zueN~lC(NuDW#EQUzHaC@RB83GbdA6#t;8{NOz%VqYRnwq25~9pT6Uq2{_FPHuf@>@ z^0(e-dKj|v_p3-Di-H6p>rAD7#ETNn^X#@noN%e4j@uGiXE|39xmG&dpIx#ZWV!!Y zh6gM!J;&}8D?z+$aD$Ii)@2>Oas4^DTE8H}ESow+S zyX=$x?ZG%fvR}{~{K8~NX`epgQC3txFW#7WsaB3Y5cTahHc6qV_mvLL5|18UJG z@B5%$_|FRed~HftlLy83O-m4w-i9t0=+O@j?ZZRlnwElqF!z7gnbnTZe3b6WO%s%e z1_)G8Pf9j3p&owT-Za^so_MqNdhZAS&EcZHubVJ|t0et(1UOmhgN<#E6gZ)Q%d(g4 zTAr8C5%0bD#mk%I(*%4LcK^>MC zDqVk*chRaK^8Q4Dv-tFX zG{koMF8EA@{^d*vRZn^M}Muq2pRAb`w9>pVTC_KtW$8vK^ z@O{gHw;GsvA-lu@|8iP)aQ-bc5Oc0B92c3--zfd$V70I8#Tlaptk=Jtbe15UDgt*` zwCmOiJDKqtku+~~P0tEDT}RrHe^`|? z^ng+{dInwBUwc7-H00_ZVE}=-xK0y11pjP{8h9?d0x7WaBuLSWt9`#URjDW%?;}PTVDOl{b zXR5s1DRo=1H0;B+;N(CjTF^gi%8f(FJ5OR)yoJo{uvKVbt@6QesNGi03 zms`|1i3p5<Vp{atok_N_RRrgNlkDYfNeUo%~^|R08(Y0I^?M2hGp2tKi5A6DZ}n3gnB8d z7XaGdWGQ83P_w6i;|nm13zp)xH`1f~pt!P7W-XO)34#Zc6~oBS_crjlZ@J`_A1pZ6 z-@-|e`G+Z#x7l^Ae^=O1_`Bl&|NcKz4xowh*Bn+WD*dl^X$710x2$Vi+S9-0<2o?A zzZnf|TF2s`6Il=&JeXp%E4+Dr7s=MFl7QM3Sv)!}|rD4gd9#zo<_C zoXxp;Gy1h)vB&D`E09!O!nInEO%T-C#Qvh=)Q5g>nCo~Il#-NNyw*JUNt$f;kKMQ2 z$uQowu4OLlOY5t-glQ>x`zYAuKZ=KkRS(Pz&Q>1&-C!InwgRe#nmsDr>zpvNA+N zDF)~CgOUHg?Efy23>EgyO)OdtFRh~wP?Ou_4taa3f*XTd`8Hy&WecN2^ffHWqu2|m zQm1tHG~vtM>z(TzPebn{`)Tjz5>Jph8|oG6ns&TLY*%hoVClJc2ajnu+EC9Uv2mAXZ7w2XzV(q{8?`S0+wfxgrq_7x?K|6?O2G);ZtyGjO&fCv z^L8G6YpRj%u_ktVt4?fFvKHV)PlN6-#>*t{Le>nhe58Q=4~rkUlYJ;JX4_Rqwg2PZ zC3{;P-nh+b?I&UL+qa#W@_;{&HRi%J_uV^Fdjl;0FrBG4dyKWjF?r9*9eHP>dVUa( z_Wq~!^TUpuT?e(C>>ihq{tptYig#(_kk0WJ)Eq7wDQFXlT7SNEXH3owmvjzynxp!r z)p+MfOg?*BS$HCh0IAG*EYL#v?rk;qC6^2jaSCW*!~Rda@{q{k=+KNX>Ws>?tqB)+ zn|^>i-%8s=(GhLeENa8FQihKbX}@FJ^ke#av@N^Nh;LjvRm6olKDHt2t{y8A$izh& zE>=%7p-Dp70=OuR)5peeF8I1C!R0us{b_+kdCBkb7U8Ab=4Zb7d{Gd^H;$tf>Rg1{ zCqhgTlZ!UC%vr#&tZV0=Y{TVRprjpC_;^Lx*Zo7S!q#DYpcKni#zpzMw)>_r3)EuW zPdeUHxslw>g!~!jx(li96Klz`9q3B5)$||`r)=wd)he15LU}DTkg_)w<+TR9Ye#Yp zW))|bpHJ};No^>&acMrnhiWmNnWPMW7Zgpr0+}b`TfdEbH6(4-sS9;P9Gadp0p@R4 zw|8vQZ}?SQoc{?BV5oHqAC~!LmPr1vL)IAUUv1KYh1U!As&&szIrM;(h#3mjy=Hat znnl0#G_R#{?of~hte7GIz1r{A<`zS++|c511I20kqyJ@P0)nWCZk(B}wKxx&4;}&7sU}?+*wMcWML0^qq9Oe%ej)Og6ULE^+cuQcNpFvGp zgrk_Vd}nwo%+CkoqmE6@Rg5+VG3A;$y1(;s{Cr+QF>4E&{h)fJa9sU(;kN7QWABi! zx+J#8L)+hO%P$N12JIwuWSfDLTsKM|s=>l7msU;Aw3lsv@dO{&D!YPhTjfshDI<;x z)`i785xT}?|FXBnp$)0U*YBUKo5e6KVmd?uqLxV?j;Y*iyl-lUuY;}3MiyAy=!TT9 z(kcm^Ltr8$ANzsaX5F*;4qbzA>ih@!G4}Ujhl9|GY?+;DnN);JxYuIcwCm_zf-B18+(b#KHDl?K*9EWuB6 zq7SCH6bX3cZ}m1R))PeoL5E|s_0ml$K0=R7dW&A+WrY>VfT%AQt>&t8gj^HIxGB*R z0+WXwPN5 z=mU9D7b3n`z%I!kXhaP+GCkF6?0#?6lyn7YymcnHtA%QIQL8EEMfvR-@W=xS(tzf~ z!+WKnuxSmv&bwMR>B;Tp{&#Hp8Yep+T750}y} zi4lD~X@wO+CNM~hzLvhKN+i#+GOdzsQz$k;q7rAAHX4YHjJy{FbYOOZEy zYg}DG5itU#trm}cza;4;bKAB$J-E!)>=<;TH0XNtx4WMR#qW$FPKOCYt%DBB#FOdQ z^)B1OGp2s^Ps+(A$m)Y;-N_w;{=X0)_eA)vRNQ-%ne^U<+`4b&l)9;=O2qwEzUddI zduM+u`Bv`1tK|y{w;PS%MQC2bR%v+PHHZ(#@a-stu{&w0>Sz_q9_F_lRCu;MYSclu zCbZoR*6mJ%+!XZYnZxKtD_o}6NI|kLuZNjl(s!deBS~hK1Vsl`Fmq!IJvNhgYH3mC z$tQ&N=BDsLD7mQ{{&pp;zyhdWFFEKXcZA+{HPksI3G2UIq^mCtecL-IzVy<^>^(ke zxZMCgEvRHCsz!as85<<{93Qcc0otIrV~SaCv*I};g+?$5l#_Q5bqmTYxFqQ)GqRZAQ6_-7pN1&YrP$0UB2<#km2FjZ~_*P!Z9lEaT~yMc8f{uM17p#s_Oa z9#f(F@v1~mv2ekC^3_{by|d22k0<&eM4175)Wu2jy+W@gGJche|5?VbHDsJ<92vKe_kpIJ9d75kIEHPG0B`BXnC^enDNLF zrNuzJ6wn-N@58Vj49=Rxj=oC*-Eq)%1Q_zTHTV=oIH9}~LSF65d!HAor3)2aAP4_O z#{Wi6w50@J?K6J*IC1fvrL`8TTM%WemY-=;W3XK5Qyk8ti}*$3==qKeOJ97%Pa4JPV(b;>O2%~N-Q=d2|Wg->y3Hsukg z%4s_l6EaccXA93XTmW0{qN!7h_4O-(qm>TF_Uf8~1Yshwl8Zm7%(Noq9askiDv)y? z2kcT`pWgvxZm9A^pE|C!n||8W+N!CXgjc4!dbFEvVSUkYXmvUPO5hdvo@1eG&oOaU zbtAE@X#q*BtuS4vT_z3IeQ`d_Nf_#??6fx0z&Nz-J~33f3Q)G4HdX+*o?PSOF|#zN zKHC6Tcwup{?!z}Dr7V1MEM&tk3|^NoeBaFbXTJYt5PioB3X0ywARyfUE5TO#u-qf6 z1RFBFL)UJ%lzK3oXzJu-eSM!C$i?ZTeECUqOTZ}}@Q!ztM%2j~68Iz`Wc*|@pS(pX z>D?t(w@=qcxvH2&i7H;ONE>}qVugs1l?gk5@XpdY=M_bTqn0FxxuAD)41&oOOw`&=itV6 za9yWFMgk<}vtB6W?BU0Lmf?{`u0PAas1RU*Lj7h7bK}j?Wr@v(=B93>u?lP4WSZhFzB*?BL{R@R!ghjGAb{4a6j@`?$Lr|bfp?8HfGR{}dE@#1MWpF4sof0o5F zqSKeJfzQb?Sx!Ht*5z=Iq0jr!AlC5SH)@API5n3@S9RQ$n<@$wRy-s2q?G4pDSEr% z+q~QK2Z(K*wM9LB9PeQv)73?J(T>RoJ(}NM%2KKo+!<6))m+Sufh6g}GK_P6u4m`G zGgGQ+5vJ$&R4A!@2G;;RbRNwQtp+H15YGr*h^!S(suisBYOHSp*HhE4a-BpWl|HufOzVB&vR9Js1gNC#EwKB@Le#*zx z^VX<`+$W*5bx7c>kW{XY9rwj-@BM0lts037WZ^hL>Z1PbhLt4~2xN2fa?!@fzF*PR zX2`^*Ljj~*^*K4CFo9KU=W}w&I?rHzl{im+gYGr>dIf-@B!r3U%%JKl=14i)Y3hr6 zNmV%)*~-|ZwU(=Yta)9+{3`RVbk@u8^>PI6iOW+AYEW7@yJ+{+wHUQ5Q>+0?w`)`R7<^(^OfkHpP25)X3S6_92yDRze+6R?6Wmq z%ylXBi3Z7iAj*+-BtfycTh%6zo!-_TU?Xoqm4$y7H7LC8DotG8f1H9ggBP##bfBk%iK|G;F=>q^6b5#$Dvw6kb89rf zrMRA>LGR-!XPs*BpZKW;v$;07lxiUX^$tIv@a5EVgYVQ^7X23iNr5L9QVpeX=8Y2NBhou!9}187?@0NPO1 zIFfmbdq#bwIy#@3WQaKHpHILD3_KfDppNSPWr)LUPfcM$!r3{hq7f;T22rdQ#9NK& z!2SVa9jVwO3?b^|Pbde4odfst%u#*o1<5;V`ywdz^L=9NnSl1dYonx&WjFghr0-%m z?r0Rgz%-IPom#+>`hTZXxvx~&6Jgn#{F*T+D_LH*HolPdNw{ImA{(BYS$UN8`QN=2 zeTTuk!PN8ij?T@#`(6F`if?)KS(SJjIo=H2FZa_uk(Q_~f4*&N8_xN_8+c<#QhJFJ zhY6lgj@AKjkN6q)sbRvn%CtUt(0K)VSdnccEsTO}K0{;~1Z^Zw%?MR%;+a(Whi!64 zoLQu3g&9OybITLt{n@SSq!VmL&wtrTHQ{|UM24wcBPT{ChrFTWjSYeTwUxo50)_Gi zV{UAb?w=4;*eCnCyfLQ@drkQtyUcp2hvH2PT?C!UkbQc$sqN9Jg**V-xDaW-mC?6n z;hJlTx(=ghWV)^*cqsi(y@Rw*`iF=OHQo`$799<{kib{9&9N-JQX9YpP+f+_KzDQ- z@Yh!h?>zWZcCzxOMY&)iT1lVl28EIPKLvTmBrmBo z2?T|*TH;-Tj<4POEbMD~T*)9IIKJuT4n_qwLor{TzGE5tIv9S_u=cqtAFwhDkOZ2Y z?Y)7Fu{`%#9#T#nP}molkk2pkBv997#MsF;j-0!+vthBSi4rvnkVUg9uTMFr*gRU$ zy0qZ+lD!pAxEUs1PN`WHbu$Znbz0Pehf|3MA zD;|a(7tVWulr?o8AZUz)W*-G>v_7cTm^$qBsjc&8@hof;S6zyBfiG)u*Q{P`tGg3C zkC_X*jo%7g&TBtV*6b{5l(bH{QoB4p^Ay8}(zU0foq4xdg=G)ZwDa;8xGrw7Heb8K zyc9O@mUM8cC)|; zvn0qqP`4j%Syy15eXw#8Wko~B2FisAAW^eqJJJsKs&GEDz|RAmOJ{`J>aVH7HtSH& zY07?d@#MR;Az6&|bUxlWyV0xv@M3=l$|5h;^#jqR78~AkV;?3+C{aZ5bXgI|#wno-Up;eAU7%O(`Fz@m zwF^#8*4qR7&=Y*AM42TB-%3{4>lG!B0wW;q%}&2&rxElhdScoyOFpnklEADG31 zSqu9L+rqP1lDOePvxU!@j!@9(xh<-5u?G;fPzm?Bv5U;*shC4sgDGEnvJw zFp5X4BAU&mmA;fSUaTX(JdKY?En@(EuhbJ!e4nFlaNC}Q3gy2!F%~P@rZe?>kP4bb zdKbb)MT-p*(6Y!Nh)@eZE+OFEaUbX+r z#<+g)l2BePRR$y|xd}?0Yzs2pMHssB`J}xc(+w|P(q0iYEHGx``yN%d0Z=W^Con72 z-71CPDZb0223w5;TaN_q_+DH2%~Fx!GDRh{U-?6Vor;fJ)j+H(-xMYZvV6iGsKEEqhXf7mR;b+|Nd~l2 zCHmAebIk8q-yaCUDIcgbcJ#L>)o)81lLTSyN9UG3gyMQ}Tj6n|Tt5a}*RQ-wmLCC2 z>dL`}3yhh}3>AgD`~iits57uz2?0z)TG7=o*9@u`JJP(E`8dwb+9qq3NxlDueI*+WLwsm<9hulm&DRYkaU&?u{~j8N-! zFOl=aMo>se$?)JMD!zCm56#9$capeT;v!HcHal%$?lcqHSQI6snA^XZm<#~HB8mf_ z>s*3+P|{m%Rs?E{jnb`YR-=rv0Hb!j{FkO(!=dy~8uoVN^>SH-3eYP*-UnGI80k3R zrY1v!C(RU@Z`zc%J{BvzbguMHGgWrlwO(s(!k%w=#A{nVw+P^dvlR%<%!q2CAfqa6+jOtX`n)!U7B6BDbG@_a35-Ee)AwJ4$XB{I(aZz@%c;cpnGXMhI zj0w{`ht(8^WhBn;awZ=ol??Oe4y|^N&Q|TPRkcG+520+ez<3t)C<_2FOIlXE7Ld=+ z!Sw^?K5iPt{uD%xfgZXU#cHkyvIy})vxM)ngHO|ydOo$r(W)X3D{PN@j{m(t`H-3a zpvnLIrKfs!hCr(umJH6-8#FlHU_y+681R?KgZV*$`uM(evM;}&J0IR^%k4sk(lJ9J zIDZgMa&Wmlzd0Hf7Y8s^N!fhG!uor1CIaM{xviLHd-6x=j_mt6<_E8s-q23GsvUUa z8Uf>)-@aBZdrS%nY))}ucq`niI!-yETH{-+1la(5!fJ`bd=uz(*zU`lo)Zps^vFX< zT_ujsgRipd@Jn&c{xNF5YZRNUw zHk|)rbeCCR7mR=yDq@^%3iEU-8~WmhIMK;)80H(w#CH_3YGsg)rRK{)Ld*amZh(+D zK)Ce-;;*T}H^3>(lkM39Q^$b-w-N+@Q7>fjBw$Yk;*ztN`sw-H)^K4J=fgSq1_AnP zW91ZG2v{^XjVwzePwr?StWCsezm>`bdzqiUwM@G4*amdY54)(q!W`o}U$dFEvVdjE zl5fg)_R5kDr9~(37OCXS3*^Fz{6qp9Il^&@G&krktY#gh9G<`J;NqL8od7B1et_ak z^Wv&T5O?EI%uP_-DTNDD7w-Do)B)p0sn-;l*Hl?t|43I3Tq~_iergA*X$sCmy|(9y zAR)HtZq;aeDsQJetW6;T|4fpL=!#26g&W>}WF)?2B)uiua`Uv=(6=uHjU&`{$GgGr z3|8CT)V_LE`;6rX|Fg7unA5VaxN|biR`$XW_;fh5_|vyWN!z5kCf7&a6Uk41WJ?8w zpc%H096YC4QE!=nZMq3OH<=&@ecDneL}}VK$;<@1m4Uxyi`TX`T4t4mE%}i%(OTvc zAL`|-nhk}$^Mp1I&}xHasB-)tTNCp(n_A%E7r&o2VMBQPZ9ku>xw+~CQJ}|Fl8VLH z2kd?_hoNBh$mQ&62@s^+oe^``dleV4fD5C3c=+M`)B}a{Uj+>zX8lMta>Z}kDY&CE zfm8QhR~Y>9L|uRn;WU*_!0U)fY1U$LGkQ$ z3sT5)-@fhNW7dSpp+-%jXf_<(KMONr=X7PL7fRW+HhhQeC13QC~!q!aaKc;V3U={ ztLf4ZD_X6?ra^%fO01X8{9rHl%6>FEJ^rzcZ@Nt~QPrj)1yYuKI;`Hr-v)2V$^ucS z`0`0WtTn5GkQcUzZ%&rpUxqDz!IZdd@(csEvP4gRre1St|Ji0A%7T6K%Nf5C!9d6X zF%Sl!kva9jSE(^m=DuHUq9qc)ysY- zGfo&L+$7i6`pgucHBxw`L-6L2H72}#S}oPlRF-UN6n^rwP%zZ|Kf_^o(g3k->nPN|z>jT_xr6~mrM@Vj`W;&l|1mo6B#?SHa!6u1qUPKP4Az{9!U}?XbTlIy=;)xK&dPy zEbWl$4BSKzJ~Hz`0qY=(b^_T=N3|qxD8#DXZ}jOZ)zgjqMt57;4%uLcm8+GCA@VYi zne0CVy8OuMl)~8IOYvS%kKCjBR7nIbpB=FP=C^J1wJoaG?rk$BCo67WB_Df44(S-} zJ8x>QqUpybERPy`1|pc$?%1Q~(>;0fDzzZfKvmnndN#zUmOxPX1lR*d6+6arKz6kT zs&@L`4&PU{seQv-J?1*^(61-y>GP~ZRVjw_1jwUK7ce^_9Ati)Qg$Se^uYE}HY9)z zPl-zo*Q)9x!}C1%XloHD8XjZv_#pCaG1rFucyF->cn?%Vj`@l9S%g|ShrV3YRpaU&6NyI9UX(yH-sHOs7i$rE2nGpMS!o1i z4_a_5ZUki&b@(8-F~u)$>MQci<$YHvRB9|B!=!$nqSQHdC!uwEm%6qvzDTX$2Xqr2Ch&Py5u8neR9g&Y>XDm$D}@7F~DWNZRpJ*DBj*m?5i1 z9+Vq(19T)D6k`tDXWvJZpmE(`EWy1DRg@cG2|pd?ob5b2-Rbdr-*k=SERdOaD3n+u zf;`zb@2czoEt6}vAe83gqwsZ1jL1Q8zt*VAZBKWjv#OD#5%@XKa|Sc^x`X_j%WhXp zZ)(S1)ebT`Ec(E0YkF@coqM)M5-Qi-34bYFv{FCJJn3r|az0`e>^~bOYn%*ORve$8y^WwwUR(;D?I*Qa$lG zw7a~5l}iqiYPXHDun@(G$xxhl`C7Hc6|CV_;D6<}xkP>ufpW|fk<)LEBl=%|Xm5L+xMAOc13z$ub+LEyEW4dYnvc_p9hrVL;R3U14WcwG zW;h3G-D>do3EojX0w$c_W(F_@<266RJ_UR*mO>Txgweyz`$?xM=Ji%e=W7^nT%MEq zG+RDI93*Ifd9KBPw{=UP#TE0>553;VCXK8?vlrA`X%yh*X37?N+pLu-xAL}_n12NGgwGp)&bg^OlxLO>8Gj{&bFXm_BI$@} z5+uwed)-IsewBDV0>G*05G{_}E#wd^7k<}8bIqT<5Z$k!6-rVhFyNSL$qmm!4bkoN zxwfs*DP&R?7=z64^$B<{*4^tJmzy^SOgV;5QT+jk?^BLR6>S9qRp%?clC&5M7vHQl zsTefNZXY==dY!G*F|t&K3uN1dN;9$p>c{5k%+w6N9&i!PClb6A?lGxn# z7?#@fL4akGCF3*}d%uEb7<*(+{7kJl>;lkqIu4T*_AT!2?(XgccN^RZFbwYQ?k>SY&=A}S?ry?==|IBSlH6z>%jBXN7pB# zu9qG0HA3n*y32@{4#1lc-N59>&@<@4y;|Rc%GXD@E8kB4fLBiB=e_G?fwQF#zGt^T z-}t_jGhIdvajFmeO`L{kst#S_vcj+c{_;Rw=TI| zMt!l;Jbit!ez{M1IOZ~Gzdk(i4?N&ce>DI2 z_PcG_Z*p86iBZw*;lo^lj90*>i$TBZ(Jr3eMd}+zddZpgo;GC}Zhp%x+Cb>UZY=B62Io64qXph}?m>DQmD-e*9^ z$(~eL5#jr8JkB3ncILce74;JzhA4c~D%(fP$}2ku;|1-`f$4@G%YF%%DEY3(+v>`~ zC)b=T`R*xg?pMrBtL{(4BHGNpv$xeR_2hgHQ>ccV1L_OL!_9SRWmqRFw- z?%_8huBEi;%6g4pB zUWTuLU3jlxJz&H&D&rk?$jT8R9c&p}y%BWh2doYR4yuFNl@1T3G-_(pO2+ zrEkeGgHh&qr9;Fz-;EG+O%@7#!_aN2D zLPo}J`%qUt8gu#Pmha5gF%OiqfN$%`AkjrsO8PZ2ZM;r)w&t!4+h90Eg|cMdZ>SJQ zRpm|X5MGP7hC3=We}mY$gI=CQ6%@tqU(SIxsT9Q*t#hv{W*Ez6oswK@8}FX`;e8al zPK;`wUnHzg<}u)P8shGRmAoF~?kIWK0~U6Vxa3!JTHI}#0}c_}gZmm@0dqpcm^?}^ zHTueuX6K8>O5KdoD<~XV?6wO&WXf?P9DbDY8`JKViDGO7dZEp(3I_$k*t=2{ZxA*! zNrDVx7ue^z&+=s%p~c&J7WH^g0D|5Mttftslbrb&+~zyIeND|S5EHMOfg4jc{c_O> ztS}EK#>UzUOd^*YWg)AOK)RL@OEt6gk5_OXh?q>83U)lt__~}W1aT$U_FIgnkWnXs zF{#65aST0yHt^ByM%fw0ysEZS@^@^uZsa$sQe6d;FFjY-szx*PJ}KrQ-QN-5Tas0v zzTQ8KJ=T<@ljBjnV)cTTK8SsZ=$ds2ZNN$4a4v~V`oN)xl5~2w+g|jIB&@>N03;#o zsF}^JQcTTu^^DFgB(O2Xkelw`y-So(MTg`?QDLB1h@J~Ry?o6Z7sFmLNG&G_q9}vovIS3nA z8vny8J&bale7T+o5v6d@|5lncS$_B;@Ay||u8M}HXxiAX7P)&Av_urKkapE$4^D;C z@I0S^i=aLxzEA=ZBa;#ixmZ*ict|o?D7+Ts%^v1B&!50C6i!zUgd|td1}vF9l$>$CMbd?A6a1z=Go!Li50QNyQ}3MLD%O|LlL zSJpOg*q|@ZL)+FrWDWj~jS@#K>yMb(#R_VvAg&Y3- zn-hGnL@Owrp@8xCm#8TY&Ja+Y3Vu^vq>q@6a9Ul)WEeo4sB+>MgAIzZ4n0ZTLnw_@ zM~u?|NZIWnmCiT7EAQl+hV%k8OzHP?$*G4-n8DDKVWtdIGZwszVXr$z&CD|W-g8F^+1&;P**tsgM z$}|j(9ZY}@8%bZn8HEX(V@uiv5AGh*dFLPrH_~2R5fqt5KhCOsC{#OQ6&RO``lC_M zH_M6Ku3`PEei|g(A z&)Z+19DW{i2@7_P?5e}aq2m|Hx8M?>;!Dfh!!~Y1wkvz!eKLj{So_d5#xkd=)=Q4ByaLX8^OUDW101|4}l1g*G% z`IGkxttR|%=umb)_Rmmda@mLOY{CN)TUY}H0+I7f9i>Kmm)sit?tqXKrz|-dthPlR z;>o&v$b8D0fZlff)9iH^Jb_|CDb5-J0tpB|nVa-Lo+yBAdYS!sZ!-NooyLA7?Vz9^ z3=;M4UvEJ&Rh}weKX(2IK`&6?v&fw_f<}eO7Q%P#@#Ld|d{9{B=ndbmr@xlAU!pOi z*@PK^xTk#HiqGimq*{bwM~$yl6jB7z%Z;;Fr0BD8;0#w32u6K?6Gb4T4-CY&@8QC5 zlp>S$gx80ZRn!@vC0UkYIf-Q&O?EB%IGUICnGZb~*Z-3P5AAX5@LJ-P=aF^BPJ-u3 zkS{!WA-P3=QS^CHIFuSv5nPi>m^fz7-F|AC z>DJSl*Z?+%zz6S6=@0D zN)oM7iku!b;69t>uDj7m6&io^n9Ud_2;|U#*3LvL6e7qGXuG%omf6}DorHnC7<8#v zMAQ!E5-21`L$sDM(%LFhQchZx2$X6iX&?tN98r@h=%0~LlF2Ibwd-0AyvnMz>`EQ z9OYyL&Z+u^Mp(pQO}-!J=eJF11(KS8TVrP69jdPqkV`&Fc=U5Vx)O4ahLkQX<=}>%dgn~4p z(0a10HN##is$4hw(6}nLIUYfJ^vX zzBkzz5dBdSR*`woz~hs!IE^3hcHLwDfj*u-Ck%xcb%n$NCT3m_LMtPY6nF>*CMZdPxsUBMzwe3(SB8}uRr?sISR&#E4$uF0$iv(jX8elNF z#x0yN1~jwa4qM^W;};DKG)^(04QmK5>Nfra`pd{o_0KBKK5wL5I8(}B*+Xoqu3_LK ze+4(&Tx{EhWIl~V<4jz82_V&$iAadhi#3Wn*M~Z?f9jGsf*Al)@CE5bD$V8iDufx@ zE=w0dn7qiQE2m0OUxhxrh<#8nh$?Jqn=|WiDooz(67{E~(vd1a5=|k6`Q{W{i(+nz zCS#X>lw#`d3WAlK@fwE3g?%So)5wyENifSCh3HlijLU=rhSxlzs4Ub}{Wm^-hrRg; ze1j$I57CCJUpDh*W$P3)c(Zs>W{`$oa3b({?ziC-@b93iIL`o4IKkET7gS&Q)6@0M zGnO;jD%pm<-669{TqurR6#Jn|B+Gts5Os!vw+cqb`LTW}N?fsx{%iYCNihzhDcJ?T zRHHjX3(g)>jeklxH6;mKHll?Zn@qQ8l4`fjKO>iMzTRk^B4u1HFzjd^!I)?cLK^Ly z6llU3g$yx)utkMP5?8mvtA!JO7S6nSyICw>Sa^=&2`$Tv=U)h2YSihJaYr5H2E#7m0_d-Ty*SJO60HC>Q%r<+Gr zT68lgiF<0M2fpWoPVa!y6^>q=R7y$mP7zLg9@xJ zWa*6(3_O((z(wIG*AcC4$Y>HXN=CpYqAe|%kAMSgdyg_UMF?W+FDLAC`_Zpko-ja@ zjT$W~;@RCKPoWV$2aEAE=_IbNM)K;~%?HLM1nNVK9gwV!Mxc#xz2b`>` z$ncX{CJKVMhyLfciRK}&Aj@YdIlE-lk8v$8k1ZQ2MPiyRj;Z}D)_-B~nhU%vUhGV20LNMn^o z4YCY7n-!TjXd%FZo|wr(86<^^>5&0Oql9vgt^ebN)5?#(d}z%jO$V(QnZXY%?pMGm zPupiyu7q51GBTZYg^^$jjdLC|*vUv=i|P8kH6-hV9W;qxg(4gew41`;VhwQJy-ehy z6@~;I3)2ndP}UKz8%&aR^9#nJ{jRv6v`k!;`xPS{#s+k07%Umv&0T`iZR}C1?~T|G zNC>g$Ut@Io=rv*QAl;3co(k9yRKcBF@F-VTC7|lqN?@DCbHqzBv;fhL-%VqtQD%me zAlT9FkSGR0=5^)$=vMf6+fx*r>39HnMP%W@u&<|zj5gfMUV;Tl zO@7e<5sBYD&!uB+K64Q@jO=pa5CoS~t~N^2k4{FoeMxagcnDPGR1A&rqjuaTbcB`M zlDe_KHz1}x8P0a6BZ=&`wF#)Ch6-T7jVM){!8n%y`xOso!&}{Plf{WBMHZeHEWVlu zd%1l%hjE9Ww2{$2C4~ouDhk!iqBxrQoSuotVFLDD0j^ZONbUmc7uvlMe<@GwbXF7J zX~8DTSQL}a(vMyO37*KIMp?{HWT-k|BQ8O-uoLIfH`*mLtJO*Szw~jh1CA)hM_Z%O4>#F@nB3Nm9MiY~P4Q*2FNV%50{e=Pg5q$>^`z1!#8_=+wI^vY zs5SokjNY2UO(Ism)js1%K29gbWNCs+HB-NWVqs%y3v++BDHZCVrN+hHd;BA z9kRG(E3^~fxPDrdfbwPDsbx)v_4F$P!5D<|1)pVYQGoyrc9|bC6@!m?N=gQyr+F(L zI)v+0y`(dx6?n^65$|rsZfLQpBYB;gs!%}6nv@GN2a(W_67GqoPsBl(vAq;#lIu1I zSyjN$HatOWLp~#EV}ykzbz}{~ft8hTOKSr6MLk{u)eWd-{6#f`>rJ||)$I8QB@*x3 zy*Sit7*T@n=QZOBCXoW<2?z!L_noC(K02cu&83tapk7sY@$o{&F=0-chmfn6f(@BY zRsHvA!E)4fmSI?@q>rVwn>h01jHg>e>6EN(A*QFba7%i4wS$MY;BQ&aZBc)d;VV8g zES5xVg$2S5!b;){2!P>N$e0SaPaTOi+{`!fS)WHJko7#J=#--EU^OU;^?j;>`KB9q z?5NKTQ+=9zuj9vUJUxQVhp`fguahIWxG6-LQC>xQ>2D5*D%?`bA^ZJO-=JX2K4Eui z?L_re{7%%6mgs~-XjYXbp4dlmhgd;CEG;;MR)=KAzq~3x_KU2{WAAObKF=WS#6Dz z43j_#s?0YnXo-4lH}|~ubn8h5i*OA%J=3Ul2poNGAsH~4Mnv@&P9cG6M+?awAdJkVi=~U z@<}@ksG}!V3M|(0F7PVAD5x_ySCB9Q}U-_ToNZ2 zXm*9HNgZ^NByPbd`L5u!#;C9%hQ1{XygwP36Q3}7f+T~(Vo4S*=ml#pc>REwJE0QU z)Vd7nnsS$An8rO2U$y$EN&@Z}baF_M4nJE}eg%blev^x_X5*am&9!z|RjzX9+S(W0 zt%xR}9TWy;wlPsJZj8-CTkNgV+z?FwF^8k^u)>c5(_#JXcR#9RLq;s55K;^Exd0oJ z4+uAvtJoduJU_{5x{5joK_Fhc@*#PG>^hH)NH{2cSLy@Q4PF1FeaWq>oRt}ZOHtL* zO9rAf$+@Dc=xWoVr1PcD9lAWPdG)p~H%l1On%4ksnwV_WEiyV2ih8i)Wd3DpN`a?kWY7X2a*>CfbJ9c!rfG2f)af zO1+g#aXc@HZF(Rs8d-TF7U6c*FG{YGD(0Sez3ByWUVhea1ygYPNNMv=vc^`Q9-F04 zc7Dba_KZ+weMnaG@xeoR*1JR#4WvL*rb@A}LzFF5v+ldeTEPP!-O@9E?CWNii52iL zTsPOi+Bv~dFj3U1SOQjC6ZfMvoGWv=KER2l9AB696VygfsxltH*jF!2=54mwQ`W5= z#3r!g$X(c~rWC$l7-P)@{t#8zHNX+tyu99$@5q{)=$`9gi6>RB<`AuD^%6fAr9mgf zUcy>dNg{+K7f<2Il$Llh#6V*t9w{vNGOR%i^>tR|5N<>~C>aw7#aJ~6A*X+r0D^z8 z!b9NZV}7FIe2uUmT7vMnkBMnxJ2~b(P*PAAm|Wf6_8x)p9x63-<&F$ojy%PXMZ_v* zsDve+HVCf$ZtcmCSFZ08)K-RSxd(f{Tn5;b7S^!W)Etm13T3hjmWOFUe=}_gCmC{UKI&qvAb z11Plm#!EomXC$g*)RM%UC~0;V%H|t^{5*%R6V}HgcFvS4;$7dFqZpGoqa6$jTv0=S z6=|w()GEyoIHbk&ifkkt5kH2OW=o}7?e~Z1ot^>BZ^Cs4QjH&uBgzsOo^z1AHxLz( zXI^G6HfmIH4SL*CFwnt<9kRvrHJfYiMBwl~6Ac%hoOzCmez8WflXzO%*o{R_dyI&tST>#S} z45*TdhtN2#Yo}ELhmEW19Y^~)$|#^0uynieUM&}Vo~U68s|EYRx7XCqgOs$mXX#%g zmCYRA&`7&yVIl`p(!*iq7gTaJPwFc8w>omi{N?D~wrkI~CFGSVT{jU)WStW-1B((d z(U<)kRTY%19Z;nvoY<1g4gnih{HOiSqLqkm`7k8!QYv^(e5kxK;1!yFw%`+fxL4y{RXw_{ZeXMWgMZyEn3b#0E+Ds(pjR7D z*5dlt2RJ4W&wZhI7F2{Qj<$JL9g<|EnXz^c(U2KGzgxRkaWhI-i55^+3r*;``FAQks~ zl@O%NWe8L>*(xErzhvoUBzf-lx>c8HKsDX&j(n?Ri2=hYhYTXWT+xNLxN%MicIpbR zAe#axe6FDds%k2X9!_hvY6V?AVX3?Qo*v||HH=3Qu>G))-JzNL+`tpOvC#%OjIH@}uP4j#)?y#$JUK-4WI#d%xBz(?858UK`T0=HD&t3a8kpxx9~R^(J~ z+RXuosgj)*IcG^U>PYnPOT0t;qL@BVsEa{LCnV z2R8CY+>Ux*ThqHVHuT%BCFW>S&sqgW0}i_vj`XXhk|3OC7y zEUp%-34$Pd12-7Gh$2GPgaS5&S&%fuW!q0zf`(KneXx)+Md|>%8I=^tnJ32ia3OGr z#B$DuV3LM3*M3ahqEoQ>%?2F?8OT=auP?3+*miW*ld#;0xng>Ji>XO^uL_aw;WCf; z=3)w)8sBPM=b&%o^8#PBEh@8atJbXc?eY9*DRG``JKc|YOSHi3DsaiV>N^mKrY{R2y+utJdLj_j9{71oJVty%sSB#y_ zpWhtw`qlN50mv1-QRc-1Z^5o1$Semvo>cuKs^t+Vo%9bk+@FC*f_yd9^{iO(3AP$%X_l#{`}}jXN`(L}kiY_HG)dC15(N zc31asaoX{{)tRjc1zK>VGa4rr6#6$q6dz=E5Z~`9IT$nF2Q{hTr)AHxe<+f1>BeCb zf5LBL(+NV%J1$Y8`T(0&{@w8W#QX_=s}hH;0ax7>EmRkq!R)9UO3;=OmDm?^F%N!T zOHT)^F}4JkATrWAN7y=nTBsRHT0BC3Fg|O1G)pRt^e1~+#4Yovg-t$c0iL?1t2rv9 z3c|R!24d!1VfwtCrPsl=AgN8WZ`$g?1lXaj4#Ura5y1qtIqR=>=qJsGaFu1G=pAv| z(>zdOAQpZl6Z;(JPCNbk4h#xj>09D-pRJJ7f9*CS`T zyoHX@{FP3U%SJgWqcDj~fbe%m5B<`%ZyAC}OvmNcZdHP?6Bsbn@$ z%xE^FHTjywld&C-J}qv)7(tN-!hcbfTDD@0cYqOxoc5ke1%%o7fv*oAoVQ+@KR*~j zW(J+dcH??}yt3$tX2B+oj*jY4DS>ne9}Joo#Lov}xlk@elR>lhZEmQC6EMxG6wv&t zK$aNdb4|lsr%fs1Lo*X7=rzFH_6{0&NuQ*gY^8dl2FqJ#_+Bs##+CMY)`baun&CI zMk#Z|KIl*rn1=hNi>X)N;~MPlzKY(3Ku)p}MULRWjy9K$){~;zw;L?<2mscm>PXc~ zdYOj_E^G)e03z8-LO*_f$Nd+C2i+=-oz%8XTW;6i?4|lFZV}^aOH(wt9I}tzn#sJ=P~vZeJzT8I0=K6ns8t z&bzS6L?V1IvM+S=MhAtg#4o3Z5?#CdPJPJA!>@wwY}6L?B*~QY6QWCF8eTBj!Ip-K zJDi!`mW*O~5AR3yK;!p${$x;GVZ{k2G9M+$inkZ_NyhO1h zroZ1EB&%IQ!5nEE##B0t%SUW_~4U4gK1vQfF>8sG17o-{Z zE1k>OLuGYJuvYA2q)L|7F?m7_$i zmtbcFFE-s*6owk{G&<3G<>~I{PLHxrK$$Dsp>~}opy#=&ahEdv&;dYQq65i6M6p0s z&>|7ThWP+pj{ALIB{hbbxO9!XD~5XrxdE9-7*D9q`C&-Aoj_@RYbidSMoLq1if-v% z4C0V(XokkqJb2I(nTgj{jLk-)(Ry42tCs>T_g7oa2S0F+Oe;ai-+r3J=}%vl)_YQL zWUXx4lIa2lsn#~HFTrvgl7>pk;-kRP4AH2M0~w^sD;azu6jNTjg551qx6jLuI^eQ5 zT0-K;54y2GSpXcZvTB0#&78WenYWf43N2aj_|1toYj`6>@4v0F2!57SJBW|*Z5O9z zxEV~^yCsJrw%q6(=H*U?DzP|vrZ_N`Sf&y{50!SU+w-GsnU2g75#X1NCbyq^2;v)L zIqV>4^M{9FH0u(`fA|b=%3q^`OgN=By3?pWdT1ui7uvt3-bXzy-!8_aT`ESf4vP}N zZNM30y^BID=ah2#%Wu~XJHfb%FU!7vcIh+ z;aTM6*#mrxnSZ5rl%b540>2!S8fFKuw^H@8_EU;vYY=M?&XSAH@890egvimk=6q`X zAiY+A!bOSRC{whm)Mp1fvlW$k3>XP)j04qiEX9%W;%gJ?tSj zZxDfyAB%}H(5-^(sTLEh#}vxe7I4VHRB$$Z@7 zfSNp`K|M#n`va?e&(tx2GXN18uJp*`ZKwi=*a(C z58c4*=PD^@>U>Fnn>XT%Txe(Evo#K1nQh`PVAap5m5)wx7+fgdn8iCDM6IiXf51vN zUfpa-fDE?r3U>W3Fu4mm^15ShN+{s6s@RB6d#$!DE%}HgKWmq2(`w=hWHu%g%3xQ0 zf2^xdW-eb6q&Xb@kTJf~xYH-Kx+_K>8{K@nw1CHInDXQIQ7a&)b?Kadqfk?6I58Jx zGm}Rb*II{l8)MwgKS)`x<}OI(Em7)q+TSa!n{WT*JV-k)6Kx*RaT9ai4>>~Y=VC%B zNdfENLMZL?HwO-9Yk@HL!=vK`<0gVmytp3H(2xjy*`z10gy49QvN(R5H$lq4qQ}O1 z9iqBS5P*HWoSGUH^E6RSK*IVdguq)QQ+fXIhJT6Ar{NQ>2Z$zXwKs`KK;tX5I2>bZ z+W`^@!&_%@H{q!bbpr4J6sd`8z`?u}GaY57)esSLt0{(A3=Qmjs~dRygp$CIUBh!-pz-rEvmgnYNR%Vo%obZs-N5|VY4&_W&WrLgqc#& zGBNTaSukUet*o+7YPvdtadte4vnOMPz>LW&JXP~l$DWdC=>5?G9m!sd#ESXtFgw*` z(mMnKl3tK6mfQA0zzK%Dmok#)OZ(Hh%43ko`Qj3=gjMwEq{2rxpgZAdee!p2~>f~We>)ME>~IaIIy|CT-p%fp){^{ov2{R zV^#`WhhinS?~s(-Q#il24x1ml&#;nA=+!X9g_gR@NVbZuSb`%hn-i3iqwj1JS3Qu= zw?5N>hoNQENWZgsCR6A@lyJD3F@kjdGQ7}6(27Mr+8Hs)^UHGWH@@-u*<|%$`BNL} z^P}dD1iyyrSExNi+LQWj_2I14n;EuRdJPw&kX0pXn2G41qT605$1+6!!*=|mU%}p= zc?Il;RUK$%opd|$H&w~10IX$GT3x=7sMSs9;6z``-bRp|GbPRd#`!+95%ndeS_JcLG%? zv`W|cMvXTpsMb$_M@iVGd8}zy9u(v2m9cO~O+(G17fFdPudA8r`LLP6`E+RmmDaeS z%&u(Ect^!BR@Z<}K&8Xgt^lwzeiUc*7)U+FjhuP=;!dW}#=+1DP0HCA{(G&*ZgCHMRIk2g!G?Nmr_vFWGayYo*&A6}kh zXiHvk&&*u9o)8rmetV~1yg|;oMM?2?e+&Wx0}r*5kO0U@Nc`hm!TV`~&wfe5vVEdN z10U7DQlz4|qd5%&a+o4N?iXv)iH89g8xD<~4-xHJbZsscK>YSo4gdc+uK*Y!hwXKxIp4m%=XOjbe`qmJHwRGmD7rvjEYcdCA*;f ziJh>>}Z(19d%41HgPDw%=Uz(--Aie-hy~44mGyqNbT;hgbJziq` zv_c_^f@TsAhJ)6$%|jw|(sNT|DzXyU*gErg!Dzv9whK%Gl3-gV8j9SLKA$cy#Kw8c zZ9q*uZ1RzYh+5)W&JpcB{c<9p6+Opqp3#4mC(d}J>R6aDl@T=~Y;Lx<8YOXTfxH&R zucL2MODqP^ft^`gEAi*u;p`jTc-x$AU4st;v`Y!s*U|78)NZpT+uD|Vp6(RuyGR$x zohOfL6Z7BCe|TEGpYzaBQWO9>*s++HIhcZ2JnbCc&w+q}35j?*ngDG;u4JYl3oCnJ zinF#Z3NkA*VG3<-B{n5T36Q0gthX~r-CJ1$=xqb!H=_{wfFR^4@J?U{ay23Iw6nE$ z5%3hI_={KI{qr9)D+SqK5LX*v3LPZ?nS_Hgh>VMci-nC@%G1i7gW>}MnUJ%YxqzCa z^xrAouY@TqU0odoSXn(hJXkz9Ssa`#SlRjc`B~XGSUEVD-x16%UiPjgp3L?xlz%Ax z#vuuE0Xkbbx>`Bdll|c|F?Dcr6{etgZzuale0GjXO8=y{clo;t?|QI$nmDquv#_z+ z*|Gkshl{I}`#Z_s1NuLDxM;i|mtj={xj48v13^;mAbVHJf2A-3{?p&l&Dr*^bj*OP zAX|{#JJjX9SN4DFQbtY*@K29F5?EN-IsWDKF8046U9HUjN34H~?N7^J>HKRT@9zKP z{WtVKa{mkbj#5$*kaPgL{RvM_QkddT{{m(XKr1tWzdmxBnS!`DxXqb4*?EA>T->H0 zW>a<^b7nSE5H}AG7m$sehy7oua@%(4A z())BWGjTPMG;syJld^Ge2(a-Auybgz^9peD39xfBvb|gW1@B;HW$yKVL;smPWI}%% za#<^v_x`>9GW~5vse_#UcJ;SQTdTjO5*gWFlS04*__q{XOx!_cf5rLE^|vOVrHQ=- z=zaD0d%6C@ZuNgC1s;Ar4t73vc4mHVUiSC#Ur2^Kb(B%-Oj3I6z!n|IY5> zVD9Q+;tUeAc-Qe=tM~H!ODi(EzXpo_-_ahHpg*JVE*LW#FZ2H{80+5?X8p5f{3Bx_ z*8fctp}zqCvdFyq{mu5ic)hQMtp8jL|E}4eW#|9n&)>)5|6>pD)c@_|KhpQVa{X7X z|44!V2>jpb`mbF7kplk__`lWl|4c4~|JhD~?BBlydAx6y`k>4I9KMG!RgjSc6Z!Ki z0VHgGx4=8f>bih|q4fQEfe$&9n7tceUFDRdV0U4WPz6xs-5SNgz({B0B*ip5mrim# zd^I#52Xh5E50EjU^SGpHnU5Jteu?VLxv3^;9^2#AR@yx*>MVS3?OhU^)6{ZGqS4ZD zlv0f*Gt{$$hakr*2uw^A|FT9(du5Wm!kOvf@?(PP^W7Eyo7wIU#a+Knm$Q~V;mM=! zaO459DV%OHFh8(suqk{|hQJUocZgx=bMC+dbc6{H21e8+hY4vHm`yVX2Mj*ZK=xP$ z2phQPq#)7Y7Z^J(Rx%>m%tMtEjunZ zIsvrdc_=AQ4UFa(W_^KM!tc{q`Fx{bSQ}1>-HzrUT|Qebdb%Y{9UHi4S-w*X@7AzB z>6E|%<{$@T33RWYhYmO>gcrzRTMgtjy|i=`8=k*1>Ev1ZWCjpZ2L(D`xo5;~b z5Qv26(dT#rpRf=k;+g{ssDmDyz@$8dCW?vzCt~9ULn&gq)1BS}5ty521%7>FpI6rO7_bT}&XjMdP5xCA*QsAOAdyZu0lr0!4V?+l9d zj=wDFu;8I+cqrdphy|4ECTft~=z@FDf_`R`V{Kf36VncuFG(K*LdC@(s=0yhC`tk4 zvWez6oqW#+wm=FY^BJcZyIs5Cd|6pIG&~nPF?5o*!Ywl$=wBs>Ql_SX-SrWFh@gpS zXBm11$?1}lA)urQF%8MqPnwIUx;E~k<5NJ`CXkBCC}^I&8NZbIf($x8`)-7_CLj|? z+Am42x@rF&8jPoQaT%Faxg(5!aQ&R7g&)C*Ra%M?BvW>$bcCzhWWPO{N+qYIg-{zo z+IFp0R|63G?(k8GZ!i{pl6GVwdt_GmiZDLR^|NhxVjp^REK)?bED%8CQ^BV$DJ>Ni zxRtDcB8DFC_cYvgh6B*hnDec0TE7pEpB-h`aSSfdWQ^#38O+;XYGlFMD5;=|iTO>+ zNMDL5#dmo*%=zP-F#apllFk4Ti!oSVIr7+bOsN8{^MM*cRJ%}}heOwfj`BKOdDbrK zfZ;pIT$Djws5B*Og3y!gdy`yVho;I^*d`AU;JQy1VfyA@S2p@mTR`E)cX>79^5cRq zejX~mj=|a=fW!$S^>n+<%kTAkvAxo=y1qX<tT6G5vat>GgENoqNUy5x+AwkW)T9r1$+DpT-;qJ|qnxqwmITNLNJ(Fp(s( zTS`R49Ym4Z=G%n3>$0#&aoP0@DGUhc+8CyEjMdbg4cTOX*pZf;ZsmTs`FTq5yD6c4 z3+1djCsoS-B20j2&F?3kqb<2(g{J;&(57?PkCH=+=!dHvz>1ee-x{TG2YpX%YWIbf z+XdnfOITX zr?zDx4~vVJ%Vke@h!<_dcl(9gL68M%TG;V`VM3zi?@5p__sByCZJ2By^6#;OUZj)1 zyoYs@N))}(_dCJo-lVSKddlU!@^aq{{8)#M7gyJt&&9hoJby5-cjn=V8s{dNefv6+ zPe)IF);9Ul96?eBFU7Y|P3v%cf{ea`dp4t*j*7Z$ z46_8p5*?sOQ4w|BhdpNYF%sb#ZqpK}S1=HgM!Uctb0@C@WIKxidh#F61D~8Ir6nUM zhzNNSJG@H+id953BMS2Z+wV=gy6Kv@HRBKmbfIqXmJ6A)ucbpadte7LsTO|Ki1{5^ zt6}ilr1A2xwDhy`*?&i{Bcl63c3zlN1QRly?|CkEw{_a4g8aV9{SZ<=;Vdpr=Cl}P zbT8~-w@f58R{YLzLmK@BDmu>a!${SZg2G7G%fw5w&)E%(6d#ZMdqvRZmh{yEVZ9bX z#XUN})I(ORE;4`_Ri>QW_S@?oOEDzuHUNo~#eR{8i^ooFE$~w^Mz!N{ll2X1^eL1( zfx<2-i}y4Ypy&`JV69{>yKFnS_;enVD~DxFz9pnAo?sUrVumSKUEPMM;nZ~x#e zO~3yYzDcJp^mt|QrP*qBE}-@(L;~co$~ifVjAd5q!OCx&F@1wnu6G3>QZa60^-#BO z30tWjGE`JZPnID)@n4xM_1+jBi?Q=qomENM**OHP_|{+aikBPI1BkQ@I}X+$=4(ux z`O=s%!(r`*TI>w%WViQH_P#K_eSUjP7r=VDC0S0W-lHZiO2_3-vo?1n|d`9AnU$UX00?QG6 zO6r8x>M_sT7dNi2{n&ud*B@~XWnzjCP0_eYtuTRytsa5de{FRs+`jNNPM-TkLb%A{iKl2^}=5!*lplS&8b+N3;(?EY#Dc69ly=1#do` zs|>oVSSRI59pj=Gr){gWLt$aPIR=fZhZDRv-L>A0j>;ZBsH`ckafC{lk^vI{I?MSA z)M!dLUmK)ok5#LC;=;jT-~_=mu0`&Xcvy=yAA(v^0 z{F{mgZUS!Y=}M_lC@x&0L;J?b%R&{kionQ=TZg#G;NAD&ZnSc9KR`p-_bgs@YDWE3 z7FJw8Ix};tn;v%08iBm(t}2+JR=4d*f12Eta`B{ei_x?1$=%Fa6OVbrG&RF`SnA(e z5o-&?S4QA{evB&49)@i3cB#G@ygjJcI!Z1Ro`TeJb5(9nY#^dTpiXP77ht6jArNE5 z*sYR@qS=9mOSaxYTTf>rW31Y5zFR{wxbF5WUZeD(vt9bFm6X#Y_;DxY@G)kIVzt9|ai&V!GWh+z zPXzSHHA}}&P2FyGnoh8r4mPOiCM1#wF9}%C{#f~-%WF&KbL$#Vb@)t$6vn^Rh(12v z0C@Mz>9!cmOEfgs&`M_Qz*@F3GxrWDEQxHtl)ssLJ`0GMY(7~HvGC5Q07-Ue}5XH!ofZNhZ+mw?4d5fbRXb+ zXU|Vm#~ozpg2Kw&?b^KojZi;mt-{W~XkR-8Aw`xbY1~?!9sBxh!B14KU7pRbx9xf4 ztYM&N?1@F>p1YlFL& zHsSlG3&K`}-~TO6`~Pa$yyMwkzc}8jvBITDDT3ChQ8lUvYS+3}w2i%LwzYy9wOX}< zXsB8(LXF01%pxJ>5~HfAt@f(z(%S3yO<%vi^E&x{pL5Q0p7;5j?=wrMdMZnJs&q6< z|JUM1!${ruSgiqRdoFP`CmGCS+yp)&Yhk%i7U5BX>?~HYj)?S$djq|X#=hGKWe7yP zviSw!#A)OGOAOj;6}cIJ>%MC*1R-Gd;O^1IfkbJXgWK)e8EI<{Zt1|2rZ&R>pLJ*U zi;;KB9)-h`Wa&Ca@l#kCT0Lv)dD3hhQnp^UPYcV0ijKYz=?)~VTaq%QTC4d{h5%Y9 zTN8KG*+cMNV;U>EuM;x?9ryU`C?w@u0&@E!q zrS++s$|XySjXr}blQu1Z9|J2ws!$6qK17MV^$*SW9LzM;jwPSj{;*y$+Ajqt&OZ=yKp_&}C7$K3ElVu?vca~qsFNO29)C$vKHQ*W0m2^7V4cS=1=HX^M`)ng?*jx|ZrC1n?dUY`YE*?2dEVlWm{2ZBGUj^ity4R^fNwR4ofMf|M<( z@9Rh7s+)RwW89|x$diU;(jEPh#s{9ch9_b)+^NrH7DSTts|)Nx`?Ezt+;{l6HH23q?+JCI0yj=qN?defgYDy3ra1ovGv%;AL-(`S@x&Ms zo_ekT#K=C4Jy@5U-QzkuLGuycDb3NMqv#iNlJ+($Rvr}uc?x~&!&wPHep>7s59F1z z2`va_soH!B8B{9{4WjV283x~M%aPG!g(476XSV=$_*=iuE8;hr4`f1Jxz2MMb#8;< zDApeA!HNI=z11|69TpuIwp=b1?yA#mL2Tyj6}a)pH*DO@XoKl`nao8UtQXw}E|eO; zf(FCk``wA#OJCSJUF%YmWvUHz$_`|Lrv`-^6d{vZKHI(s|MKoe zoYRK2|CFIURG#p=aI}WJFj+iam`Qp}_0qD{sbQhI)&7J~WznS5)Ey;=$8hGp>S59GAk(LPttLLP)WjTo6W%g=54X)Gya3;vrKjM%$HRhdRdc#7&L|nHVIT2De5oX0HBH}dJ$qZ3v zJNUEr(4ufhVJk|68P`q0lsmUt@)^jLc9hvqhi(C4{}^_}ips&)MUj!zjer*VIHX_g zO_W!L=-rvPnN^({OH1q#tF&)(Jag+vDErUuL>?>G#)_D=LeHpUZb z;-7dsyY(ebYvzskaYN}2 zddT@foPn74pNhu@YLQPZS3fgj>F7z{gx7j+cyUb)SNLhmOB44A<8xgThCMyetZ%r* zBp6O8)F68?lmm%2eQt02yt$)cI`y4$qW~8ev(;MV9q0d<_GmmQ{q?K;X@}Ou;_@pW z_udwkC!PFMH`X*Wa4bdCNQps9E=LyWrtUURcVjK9`qf?AMNTeo!HM^x_dAU`+VnOYLNzbd7kUsp}m0IbV+v_z``LBi~ z>_RKT^5P|XdX4SX_=J%uRGVssFxT3t^Hjs=4=+5!u@36HBVqfK*+>Xy&rJnauTH`n zSKDVr1e<%c3CH(MvyRYZ3=@A+nVH5vZ0uU#c88xk>7z)|sz=K&^b4WvzPkE2@^?CI%F-p(v2awg^> z_8UkC9GSvHi~8@=vY@Zw(9L?Uv?}#~m333h;{?aSwH9K6y!~qernf{ow6L@&jjLIt z<76-pi{K4Eh(`{79De8Q)A5>JVAe^e=54VFp8hpnQ(19w$IBJ;C}EndD+Ys$8EI85 z5<#p`dEDmoz(99ZOKPIW4~1P_sTVTh_r--$>S@APidHK?5;wZ>&c<5XP!9H&=9G|p zePNj){ep;Jyt3CSaDpW!6^En5eH3DTXD7QsVqn1eM#X509nnJP>LnGZtLx^sSRdKm zB`r$+6~(W@5NB?%mDp`fjD}+Y=B{0!RiDQePQq`zHXAJ}2nIdU_WVy|`?z zE-B#PgdLQD*iRy7PpSUzvOtg1Dw~PiK3OJG!`F`X4)$ z(AdDVj7i(!z9yUq#18N|>UB^|%v30%i}T0P{ytrYM-_k_5*H(dsoW%dosQwo+HsS% zeeS2uN@6KzZc=kPDo(VOi7O-WYD^9cm@iu#l=Wb51i8Sw2oz?8yftw(^A za&n^m=60c;fVFKI;CWN7p!g>i?YeIq0NRgg_YlhHqLicntA0Ky3=#O|dyl$m%$p@Z z4Ad|9s_qbxg?BZa#EqI2f}j}qUGw%g=?OzCU9AnC;$e@8q}d`2lSZyTx^xv0GncGr zq_r)}CptuB$CVW-I|@G`T{x6p4VvyQdssy@pO6X&U2j^OIN!XC-fHD4nvpmkY5WN; zMPM^!J_|!?mSK;nOx)FlWJtQ5T6%W6*jh@Fe9Uv}mRw9<>l4W31mF|VT=AV>L)c6h z1u$5Ft^6_Q@ng6Hw~HuOG&?=&es0#_HDJNBsbF1uDfuCRWac20QPa<<2!4PKUb~qF z4>KTl*HZh6z`aqTxCX`;Q-OuTAK z1HRROZzBJiKoAe@dYGuHf6I7W$Ut34Tj&9K2W7EOMCEeI~ z^R&TlCU-rCU#TBkdFJ`9TW-1a02|`Cvb))|jG?xMDlv$GZA(?nR6|pF6-qN~0N9&~ zAbOC;>vr3L7U?;hu%BxnHp~wDjs(U!H@558!7l-MKPxed2rR!=LcAOn4f=lnrYy4 z?$OcO)YJ{>E%@5pxQ)N4$?e7hVvL4GnWMjB81AN3hP_14fWOBtUc)NzJEWw8RJQ4G zMBaPh?cPcr%qiiN&xR4|KIFlfxvLZ-0R=RL5!?B* z?DPHS@{`QYcI9PWm!AYe*kB#FAqSaZ9^=)b4BagR7OW@ph>m4QF77!J!m;d{zN&sD zEm>4C`x!n%R|+kJyH6xh$})~U0PVElrG{0rjOx4|jk{&K0?U<#gW*N}peodd`$IFq z=i!vz=DoG2D_T-^IJJ?3Ii|Ft`A~+pz$K($N2pHz1Q)Czi`(bN^=V?sMKr7W(`m?H zqgZJ@QwD z;5-xhXBbghpzl#dz6P-uGe79La1Wsqv6EmqR(U!hl=9ncOev^R#!HUAL&rQ}n=W>_ zn>b+sJKE0xRN1%&PRH4I&uiQ;;^F~$11&^F#kOa^qJ3XxZ52e5M9gEhLx-1b?dnzp zpr=PGaq}(5T}eqzDRmw{`(lu1VbxwX_op?*0eTGIX4O-qnLY&TO}F@!eQndC=oe$R zD8d!9v2O7Se*6}q{Y9)I-~F{ z6u*?ulgOEM=l%osgD8Z>KwKe5WPmTNo}?YRx~5yne-LX)u$=fRPv4Pjj_9LDvw(&V zz+|PLUE$tqYksfOMOHHj8raor{$rT_j9Xdp5SnLjrcZ;P zbl^%4+bC!f7+A$GBPD2^%?n_*P~%>vjeq)~^ds;9Rddxo0dzg+%^{e)QZmA-=OlPI z=DrG-v{`BquoZypTMt2{kT4Sun#6EG;2c=+0-;cZu>)xb6aJvLANp-|W;EU0Rm^4M zxS&cPPQa!8xvO@GE_f%}v5Fi6F@7gt);K2s!*3zGJYmu;mQ!574>4HyH@7KW zN#uL>gO6+{cBlMQfbs0~DkWjGW{7Ug9U!_WMuh=|!C3}SDx;B4ES%7xLDG6ysEGu| zli!o_GkY|yQrw1PSzzppgdyhq;J8SQ)V|WcCy_>v+TdXtWP|#w+J`h~5Y2qfRfxcV z?>$dCugoZ+D@IrU+qw?wkW@Cw?tA;U`V0fxl@U;WrZL%|fxBi_0E&gbB4xs2omhYg zq+3tyIr0zE@W#Qv6agv>cQqv#DJ-f2(j~J&n3iE<3UUfO0_ed9aU}Km3n-nZZ=pGC zyxSM`bYrz=D7w*3;(R4_ebLFmG4t5f@PP;|{7VhqoWFRtS;!0mvz9tD499&x;-ABA z$??(bTkyUCQ0ivn-X3oQ<>^cp00}2USD|Z+P}kPSyue;~3ymYa8`?Wm#&(H(qeQeT zWGh@H?G9~31Zc?SX{a1mj6ae>p1dWc(kZ+QYkPzK@hAcrKi59ZVOfD3S=HE8dzH4K z_(*8@QR*4a$cQ Date: Tue, 23 Jul 2024 11:51:54 +0200 Subject: [PATCH 08/13] Adjust plugin info --- .traefik.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.traefik.yml b/.traefik.yml index 6ab3575..954e27f 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -3,9 +3,10 @@ type: middleware iconPath: .assets/icon.png import: github.com/RiskIdent/traefik-tls-headers-plugin +basePkg: plugin summary: 'Add TLS information to request headers' testData: - Headers: - X-TLS-Cipher: TLS_AES_128_GCM_SHA256 + headers: + cipher: X-TLS-Cipher From 94a162414364b20ddcbeb39963ab17f282b46bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco?= Date: Tue, 23 Jul 2024 11:52:26 +0200 Subject: [PATCH 09/13] Setup a local testing environment --- Dockerfile | 8 + Makefile | 13 +- readme.md | 289 ++++++++----------------------------- testconfig/dynamic.yml | 22 +++ testconfig/printheaders.py | 18 +++ testconfig/traefik.yml | 18 +++ 6 files changed, 140 insertions(+), 228 deletions(-) create mode 100644 Dockerfile create mode 100644 testconfig/dynamic.yml create mode 100644 testconfig/printheaders.py create mode 100644 testconfig/traefik.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a9db8cf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +ARG TRAEFIK_VERSION=v3.0.0 +ARG BASE_IMAGE=docker.io/traefik:${TRAEFIK_VERSION} +FROM ${BASE_IMAGE} + +COPY testconfig/traefik.yml /etc/traefik/traefik.yml +COPY testconfig/dynamic.yml /etc/traefik/dynamic.yml + +COPY . plugins-local/src/github.com/RiskIdent/traefik-tls-headers-plugin diff --git a/Makefile b/Makefile index 04f6f05..d1c5818 100644 --- a/Makefile +++ b/Makefile @@ -17,4 +17,15 @@ vendor: go mod vendor clean: - rm -rf ./vendor \ No newline at end of file + rm -rf ./vendor + +start_headers_reader: + python3 testconfig/printheaders.py + +testcontainer: + docker build -t traefiktest . + docker run\ + --rm \ + --name traefiktest \ + --network host \ + -it traefiktest diff --git a/readme.md b/readme.md index 353f34d..38e2d34 100644 --- a/readme.md +++ b/readme.md @@ -1,270 +1,105 @@ -This repository includes an example plugin, `demo`, for you to use as a reference for developing your own plugins. +# Traefik TLS headers plugin -[![Build Status](https://github.com/traefik/plugindemo/workflows/Main/badge.svg?branch=master)](https://github.com/traefik/plugindemo/actions) - -The existing plugins can be browsed into the [Plugin Catalog](https://plugins.traefik.io). - -# Developing a Traefik plugin - -[Traefik](https://traefik.io) plugins are developed using the [Go language](https://golang.org). - -A [Traefik](https://traefik.io) middleware plugin is just a [Go package](https://golang.org/ref/spec#Packages) that provides an `http.Handler` to perform specific processing of requests and responses. - -Rather than being pre-compiled and linked, however, plugins are executed on the fly by [Yaegi](https://github.com/traefik/yaegi), an embedded Go interpreter. +[![Main workflow](https://github.com/RiskIdent/traefik-tls-headers-plugin/actions/workflows/main.yml/badge.svg)](https://github.com/RiskIdent/traefik-tls-headers-plugin/actions/workflows/main.yml) +[![Go matrix workflow](https://github.com/RiskIdent/traefik-tls-headers-plugin/actions/workflows/go-cross.yml/badge.svg)](https://github.com/RiskIdent/traefik-tls-headers-plugin/actions/workflows/go-cross.yml) ## Usage -For a plugin to be active for a given Traefik instance, it must be declared in the static configuration. - -Plugins are parsed and loaded exclusively during startup, which allows Traefik to check the integrity of the code and catch errors early on. -If an error occurs during loading, the plugin is disabled. - -For security reasons, it is not possible to start a new plugin or modify an existing one while Traefik is running. - -Once loaded, middleware plugins behave exactly like statically compiled middlewares. -Their instantiation and behavior are driven by the dynamic configuration. +This plugin will take TLS information from the client connection and write them to some headers. -Plugin dependencies must be [vendored](https://golang.org/ref/mod#vendoring) for each plugin. -Vendored packages should be included in the plugin's GitHub repository. ([Go modules](https://blog.golang.org/using-go-modules) are not supported.) +```yaml +middlewares: + my-middleware: + plugin: + tlsheaders: + headers: + cipher: X-TLS-Cipher +``` ### Configuration -For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages). +Traefik static configuration must define the module name (as is usual for Go packages). The following declaration (given here in YAML) defines a plugin: +
File (YAML) + ```yaml # Static configuration experimental: plugins: - example: - moduleName: github.com/traefik/plugindemo - version: v0.2.1 -``` - -Here is an example of a file provider dynamic configuration (given here in YAML), where the interesting part is the `http.middlewares` section: - -```yaml -# Dynamic configuration - -http: - routers: - my-router: - rule: host(`demo.localhost`) - service: service-foo - entryPoints: - - web - middlewares: - - my-plugin - - services: - service-foo: - loadBalancer: - servers: - - url: http://127.0.0.1:5000 - - middlewares: - my-plugin: - plugin: - example: - headers: - Foo: Bar + tlsheaders: + moduleName: github.com/RiskIdent/traefik-tls-headers-plugin + version: v0.1.0 ``` -### Local Mode +
-Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub. -To use a plugin in local mode, the Traefik static configuration must define the module name (as is usual for Go packages) and a path to a [Go workspace](https://golang.org/doc/gopath_code.html#Workspaces), which can be the local GOPATH or any directory. +
CLI -The plugins must be placed in `./plugins-local` directory, -which should be in the working directory of the process running the Traefik binary. -The source code of the plugin should be organized as follows: +```bash +# Static configuration -``` -./plugins-local/ - └── src - └── github.com - └── traefik - └── plugindemo - ├── demo.go - ├── demo_test.go - ├── go.mod - ├── LICENSE - ├── Makefile - └── readme.md +--experimental.plugins.tlsheaders.moduleName=github.com/RiskIdent/traefik-tls-headers-plugin +--experimental.plugins.tlsheaders.version=v0.1.0 ``` -```yaml -# Static configuration +
-experimental: - localPlugins: - example: - moduleName: github.com/traefik/plugindemo -``` -(In the above example, the `plugindemo` plugin will be loaded from the path `./plugins-local/src/github.com/traefik/plugindemo`.) +
Kubernetes ```yaml # Dynamic configuration -http: - routers: - my-router: - rule: host(`demo.localhost`) - service: service-foo - entryPoints: - - web - middlewares: - - my-plugin - - services: - service-foo: - loadBalancer: - servers: - - url: http://127.0.0.1:5000 - - middlewares: - my-plugin: - plugin: - example: - headers: - Foo: Bar +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: my-middleware +spec: + plugin: + tlsheaders: + headers: + cipher: X-TLS-Cipher ``` -## Defining a Plugin - -A plugin package must define the following exported Go objects: - -- A type `type Config struct { ... }`. The struct fields are arbitrary. -- A function `func CreateConfig() *Config`. -- A function `func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error)`. - -```go -// Package example a example plugin. -package example - -import ( - "context" - "net/http" -) - -// Config the plugin configuration. -type Config struct { - // ... -} +
-// CreateConfig creates the default plugin configuration. -func CreateConfig() *Config { - return &Config{ - // ... - } -} +### Test locally -// Example a plugin. -type Example struct { - next http.Handler - name string - // ... -} +In order to test the plugin locally, start the printheaders application: -// New created a new plugin. -func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { - // ... - return &Example{ - // ... - }, nil -} - -func (e *Example) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - // ... - e.next.ServeHTTP(rw, req) -} +```bash +make start_headers_reader ``` -## Logs - -Currently, the only way to send logs to Traefik is to use `os.Stdout.WriteString("...")` or `os.Stderr.WriteString("...")`. - -In the future, we will try to provide something better and based on levels. - -## Plugins Catalog - -Traefik plugins are stored and hosted as public GitHub repositories. - -Every 30 minutes, the Plugins Catalog online service polls Github to find plugins and add them to its catalog. - -### Prerequisites - -To be recognized by Plugins Catalog, your repository must meet the following criteria: +Then start Traefik with the plugin: -- The `traefik-plugin` topic must be set. -- The `.traefik.yml` manifest must exist, and be filled with valid contents. - -If your repository fails to meet either of these prerequisites, Plugins Catalog will not see it. - -### Manifest - -A manifest is also mandatory, and it should be named `.traefik.yml` and stored at the root of your project. - -This YAML file provides Plugins Catalog with information about your plugin, such as a description, a full name, and so on. - -Here is an example of a typical `.traefik.yml`file: - -```yaml -# The name of your plugin as displayed in the Plugins Catalog web UI. -displayName: Name of your plugin - -# For now, `middleware` is the only type available. -type: middleware - -# The import path of your plugin. -import: github.com/username/my-plugin - -# A brief description of what your plugin is doing. -summary: Description of what my plugin is doing - -# Medias associated to the plugin (optional) -iconPath: foo/icon.png -bannerPath: foo/banner.png - -# Configuration data for your plugin. -# This is mandatory, -# and Plugins Catalog will try to execute the plugin with the data you provide as part of its startup validity tests. -testData: - Headers: - Foo: Bar +```bash +make testcontainer ``` -Properties include: - -- `displayName` (required): The name of your plugin as displayed in the Plugins Catalog web UI. -- `type` (required): For now, `middleware` is the only type available. -- `import` (required): The import path of your plugin. -- `summary` (required): A brief description of what your plugin is doing. -- `testData` (required): Configuration data for your plugin. This is mandatory, and Plugins Catalog will try to execute the plugin with the data you provide as part of its startup validity tests. -- `iconPath` (optional): A local path in the repository to the icon of the project. -- `bannerPath` (optional): A local path in the repository to the image that will be used when you will share your plugin page in social medias. - -There should also be a `go.mod` file at the root of your project. Plugins Catalog will use this file to validate the name of the project. +The traefik test configuration is located in the testconfig directory. -### Tags and Dependencies +And finally, make a request to the Traefik instance: -Plugins Catalog gets your sources from a Go module proxy, so your plugins need to be versioned with a git tag. - -Last but not least, if your plugin middleware has Go package dependencies, you need to vendor them and add them to your GitHub repository. - -If something goes wrong with the integration of your plugin, Plugins Catalog will create an issue inside your Github repository and will stop trying to add your repo until you close the issue. - -## Troubleshooting - -If Plugins Catalog fails to recognize your plugin, you will need to make one or more changes to your GitHub repository. +```bash +curl https://localhost -k +``` -In order for your plugin to be successfully imported by Plugins Catalog, consult this checklist: +The response should contain the header(s) you set up. -- The `traefik-plugin` topic must be set on your repository. -- There must be a `.traefik.yml` file at the root of your project describing your plugin, and it must have a valid `testData` property for testing purposes. -- There must be a valid `go.mod` file at the root of your project. -- Your plugin must be versioned with a git tag. -- If you have package dependencies, they must be vendored and added to your GitHub repository. +``` +Host: localhost +User-Agent: curl/7.81.0 +Accept: */* +X-Forwarded-For: 127.0.0.1 +X-Forwarded-Host: localhost +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: ri-t-0940 +X-Real-Ip: 127.0.0.1 +X-Tls-Cipher: TLS_AES_128_GCM_SHA256 +Accept-Encoding: gzip +``` diff --git a/testconfig/dynamic.yml b/testconfig/dynamic.yml new file mode 100644 index 0000000..1bf63a1 --- /dev/null +++ b/testconfig/dynamic.yml @@ -0,0 +1,22 @@ +http: + routers: + my-router: + rule: "PathPrefix(`/`)" + service: my-service + entryPoints: + - websecure + middlewares: + - my-middleware + + services: + my-service: + loadBalancer: + servers: + - url: "http://localhost:8888" + + middlewares: + my-middleware: + plugin: + tlsheaders: + headers: + cipher: X-TLS-Cipher diff --git a/testconfig/printheaders.py b/testconfig/printheaders.py new file mode 100644 index 0000000..8f2db7c --- /dev/null +++ b/testconfig/printheaders.py @@ -0,0 +1,18 @@ +import http.server +import socketserver + + +class MyRequestHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + response_body = "\n".join([f"{header}: {value}" for header, value in self.headers.items()]) + + self.send_response(200) + self.end_headers() + self.wfile.write(response_body.encode("utf-8")) + + +PORT = 8888 +if __name__ == "__main__": + with socketserver.TCPServer(("", PORT), MyRequestHandler) as httpd: + print(f"Serving on port {PORT}") + httpd.serve_forever() diff --git a/testconfig/traefik.yml b/testconfig/traefik.yml new file mode 100644 index 0000000..a90ae1c --- /dev/null +++ b/testconfig/traefik.yml @@ -0,0 +1,18 @@ +entryPoints: + websecure: + address: ":443" + http: + tls: {} + +providers: + file: + filename: "/etc/traefik/dynamic.yml" + +api: + dashboard: true + insecure: true + +experimental: + localPlugins: + tlsheaders: + moduleName: github.com/RiskIdent/traefik-tls-headers-plugin From 9142e029ad1aee6e4dba537d7c89743b096f061e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco?= Date: Fri, 26 Jul 2024 14:32:06 +0200 Subject: [PATCH 10/13] Update the docs --- readme.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/readme.md b/readme.md index 38e2d34..25e8fad 100644 --- a/readme.md +++ b/readme.md @@ -16,6 +16,9 @@ middlewares: cipher: X-TLS-Cipher ``` +## Supported fields +- `cipher`: The cipher used for the connection. See the docs [CipherSuiteName](https://pkg.go.dev/crypto/tls#CipherSuiteName) for more information. + ### Configuration Traefik static configuration must define the module name (as is usual for Go packages). @@ -85,21 +88,11 @@ The traefik test configuration is located in the testconfig directory. And finally, make a request to the Traefik instance: ```bash -curl https://localhost -k +curl -sS https://localhost -k | grep X-Tls-Cipher ``` The response should contain the header(s) you set up. ``` -Host: localhost -User-Agent: curl/7.81.0 -Accept: */* -X-Forwarded-For: 127.0.0.1 -X-Forwarded-Host: localhost -X-Forwarded-Port: 443 -X-Forwarded-Proto: https -X-Forwarded-Server: ri-t-0940 -X-Real-Ip: 127.0.0.1 X-Tls-Cipher: TLS_AES_128_GCM_SHA256 -Accept-Encoding: gzip ``` From 33672a09c9eac4335f5a72267e8ffe76a58457f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco?= Date: Fri, 26 Jul 2024 14:53:56 +0200 Subject: [PATCH 11/13] Add image attribution --- readme.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/readme.md b/readme.md index 25e8fad..26af879 100644 --- a/readme.md +++ b/readme.md @@ -96,3 +96,7 @@ The response should contain the header(s) you set up. ``` X-Tls-Cipher: TLS_AES_128_GCM_SHA256 ``` + +## Credits + +Icon made by https://www.flaticon.com/de/kostenloses-icon/tls-protokoll_4896619 From a150ad7cea6d2ddf98edeb2c90b6753e711eaf7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco=20=28Akiel=29?= Date: Fri, 26 Jul 2024 14:56:30 +0200 Subject: [PATCH 12/13] Rename header from X-TLS-Cipher to X-Tls-Cipher Co-authored-by: kalle (jag) <2477952+applejag@users.noreply.github.com> --- plugin_test.go | 4 ++-- readme.md | 4 ++-- testconfig/dynamic.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugin_test.go b/plugin_test.go index 8527125..6921da9 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -19,9 +19,9 @@ func TestInvalidConfig(t *testing.T) { func TestTLSCipher(t *testing.T) { cfg := CreateConfig() - cfg.Headers.Cipher = "X-TLS-Cipher" + cfg.Headers.Cipher = "X-Tls-Cipher" next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - assertHeader(t, r.Header, "X-TLS-Cipher", "TLS_AES_128_GCM_SHA256") + assertHeader(t, r.Header, "X-Tls-Cipher", "TLS_AES_128_GCM_SHA256") }) handler, err := New(context.Background(), next, cfg, "traefik-tls-headers-plugin") if err != nil { diff --git a/readme.md b/readme.md index 26af879..10ce4e0 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,7 @@ middlewares: plugin: tlsheaders: headers: - cipher: X-TLS-Cipher + cipher: X-Tls-Cipher ``` ## Supported fields @@ -64,7 +64,7 @@ spec: plugin: tlsheaders: headers: - cipher: X-TLS-Cipher + cipher: X-Tls-Cipher ``` diff --git a/testconfig/dynamic.yml b/testconfig/dynamic.yml index 1bf63a1..c9ecda4 100644 --- a/testconfig/dynamic.yml +++ b/testconfig/dynamic.yml @@ -19,4 +19,4 @@ http: plugin: tlsheaders: headers: - cipher: X-TLS-Cipher + cipher: X-Tls-Cipher From 4c1b24b790fbf6388688f417e245926a7b389cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Alberto=20D=C3=ADaz=20Orozco=20=28Akiel=29?= Date: Fri, 26 Jul 2024 14:57:37 +0200 Subject: [PATCH 13/13] Rename header from X-TLS-Cipher to X-Tls-Cipher Co-authored-by: kalle (jag) <2477952+applejag@users.noreply.github.com> --- .traefik.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.traefik.yml b/.traefik.yml index 954e27f..00109fd 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -9,4 +9,4 @@ summary: 'Add TLS information to request headers' testData: headers: - cipher: X-TLS-Cipher + cipher: X-Tls-Cipher