diff --git a/.werft/values.dev.yaml b/.werft/values.dev.yaml index dbb389ac9b1d64..b582f1abf7e926 100644 --- a/.werft/values.dev.yaml +++ b/.werft/values.dev.yaml @@ -8,11 +8,7 @@ hostname: staging.gitpod-dev.com imagePrefix: eu.gcr.io/gitpod-core-dev/build/ certificatesSecret: secretName: proxy-config-certificates - fullChainName: tls.crt - chainName: tls.crt - keyName: tls.key version: not-set -forceHTTPS: false imagePullPolicy: Always affinity: nodeAffinity: diff --git a/chart/templates/proxy-configmap.yaml b/chart/templates/proxy-configmap.yaml new file mode 100644 index 00000000000000..649489691f3340 --- /dev/null +++ b/chart/templates/proxy-configmap.yaml @@ -0,0 +1,50 @@ +# Copyright (c) 2021 Gitpod GmbH. All rights reserved. +# Licensed under the MIT License. See License-MIT.txt in the project root for license information. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.components.proxy.name }}-config + labels: + app: {{ template "gitpod.fullname" $ }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +data: + vhost.empty: | + # Placeholder to avoid errors loading files using a glob pattern +{{- if index .Values "minio" "enabled" }} + vhost.minio: | + https://minio.{$GITPOD_DOMAIN} { + import enable_log + import remove_server_header + import ssl_configuration + + reverse_proxy {{ index .Values "minio" "fullnameOverride" }}.{{ .Release.Namespace }}.{$KUBE_DOMAIN}:9000 { + flush_interval -1 + } + } +{{- end }} +{{- if index .Values "docker-registry" "enabled" }} +{{- if index .Values "docker-registry" "authentication" -}} +{{ $t := set . "username" (index .Values "docker-registry" "authentication" "username") }} +{{ $t := set . "password" (index .Values "docker-registry" "authentication" "password") }} +{{- else }} +{{ $t := set . "username" (randAlphaNum 20) }} +{{ $t := set . "password" (randAlphaNum 20) }} +{{- end }} + vhost.docker-registry: | + https://minio.{$GITPOD_DOMAIN} { + import enable_log + import remove_server_header + import ssl_configuration + + basicauth bcrypt "Docker Registry" { + {{ .username }} {{ bcrypt .password | b64enc }} + } + + reverse_proxy https://{{ index .Values "docker-registry" "fullnameOverride" }}.{{ .Release.Namespace }}.{$KUBE_DOMAIN} { + flush_interval -1 + } + } +{{- end }} diff --git a/components/proxy/BUILD.yaml b/components/proxy/BUILD.yaml index a31ef3570b2509..f97969bdbb4fed 100644 --- a/components/proxy/BUILD.yaml +++ b/components/proxy/BUILD.yaml @@ -3,8 +3,7 @@ packages: type: docker srcs: - "conf/**" - - "startup/**" - - "nodomain-certs/**" + - "plugins/**" argdeps: - imageRepoBase config: diff --git a/components/proxy/Dockerfile b/components/proxy/Dockerfile index 993c78329f67b2..e8a00d4801cd80 100644 --- a/components/proxy/Dockerfile +++ b/components/proxy/Dockerfile @@ -2,55 +2,11 @@ # Licensed under the GNU Affero General Public License (AGPL). # See License-AGPL.txt in the project root for license information. -FROM openresty/openresty:1.19.3.1-3-alpine - -ENV TRIGGER_REBUILD 1 +FROM aledbf/caddy-http2:0.5 # Debug convenience ENV TERM=xterm ENV SHELL=/bin/bash -RUN apk add --no-cache \ - vim \ - less \ - bind-tools \ - curl \ - apache2-utils \ - gettext \ - bash - -# Include certbot into the proxy for HTTPS termination -RUN curl -o /usr/bin/lama -sSL https://github.com/csweichel/lama/releases/download/v0.3.0/lama_0.3.0_Linux_x86_64 \ - && chmod +x /usr/bin/lama \ - && mkdir -p /var/www/lama/nginx \ - && touch /var/www/lama/nginx/status - -RUN apk add --no-cache \ - procps \ - certbot \ - certbot-nginx - -RUN set -e \ - && apk add --no-cache git \ - && cd /tmp \ - && git clone https://github.com/cloudflare/lua-resty-cookie/ \ - && cp lua-resty-cookie/lib/resty/*.lua /usr/local/openresty/site/lualib/ \ - && apk del git \ - && rm -rf /tmp/* - -# Update alpine packages -RUN apk upgrade --no-cache - -# nginx config templates... -#COPY conf/ /etc/nginx/ -# .. and startup script -COPY startup/nginx.sh /nginx.sh - -COPY conf/lua-prometheus /etc/nginx/lua-prometheus - -# ip.mygitpod.com HTTPS support -COPY nodomain-certs/* /nodomain-certs/ - -# Run! -EXPOSE 8080 -CMD ["/nginx.sh"] +COPY conf/Caddyfile /etc/caddy/Caddyfile +COPY conf/vhost.empty /etc/caddy/vhosts/vhost.empty diff --git a/components/proxy/conf/Caddyfile b/components/proxy/conf/Caddyfile new file mode 100644 index 00000000000000..877d8236b2a885 --- /dev/null +++ b/components/proxy/conf/Caddyfile @@ -0,0 +1,244 @@ +{ + # disable automatic SSL certificate generation + auto_https off + # disable admin API server + admin off + + # set default SNI for old clients + default_sni {$GITPOD_DOMAIN} + + # debug + + # configure plugin order + # https://caddyserver.com/docs/caddyfile/directives#directive-order + order gitpod.cors_origin before header + order gitpod.workspace_download before redir +} + +(compression) { + encode zstd gzip +} + +# configure headers to force HTTPS and enable more strict rules for the browser +(security_headers) { + header { + # enable HSTS + Strict-Transport-Security max-age=31536000 + # disable clients from sniffing the media type + X-Content-Type-Options nosniff + # Define valid parents that may embed a page + Content-Security-Policy "frame-ancestors self https://*.{$GITPOD_DOMAIN} https://{$GITPOD_DOMAIN}" + # keep referrer data off of HTTP connections + Referrer-Policy no-referrer-when-downgrade + # Enable cross-site filter (XSS) and tell browser to block detected attacks + X-XSS-Protection "1; mode=block" + + defer # delay changes + } +} + +(enable_log) { + log { + output stdout + format filter { + wrap json + fields { + logger delete + msg delete + size delete + status delete + resp_headers delete + request delete + } + } + } +} + +(remove_server_header) { + header { + -server + -x-powered-by + } +} + +(ssl_configuration) { + tls /etc/caddy/certificates/tls.crt /etc/caddy/certificates/tls.key { + #ca_root + } +} + +(upstream_headers) { + header_up X-Real-IP {http.request.remote.host} +} + +(upstream_connection) { + lb_try_duration 1s +} + +(debug_headers) { + header X-Gitpod-Region {$GITPOD_INSTALLATION_LONGNAME} +} + +# Kubernetes health-check +:8003 { + respond /live 200 + respond /ready 200 +} + +# always redirect to HTTPS +http:// { + redir https://{host}{uri} permanent +} + +https://{$GITPOD_DOMAIN} { + import enable_log + import remove_server_header + import ssl_configuration + import security_headers + + @workspace_download path /workspace-download* + handle @workspace_download { + header { + # The browser needs to see the correct archive content type to trigger the download. + content-type "application/tar+gzip" + -x-guploader-uploadid + -etag + -x-goog-generation + -x-goog-metageneration + -x-goog-hash + -x-goog-stored-content-length + -x-gitpod-region + -x-goog-stored-content-encoding + -x-goog-storage-class + -x-goog-generation + -x-goog-metageneration + -cache-control + -expires + + defer # delay changes + } + + gitpod.workspace_download { + service http://server.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:3000 + } + + redir {http.gitpod.workspace_download_url} 303 + } + + @backend_wss path /api/gitpod + handle @backend_wss { + uri strip_prefix /api + reverse_proxy server.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:3000 { + import upstream_headers + } + } + + @backend path /api/* /admin/* + handle @backend { + gitpod.cors_origin { + base_domain {$GITPOD_DOMAIN} + } + + import compression + + uri strip_prefix /api + reverse_proxy server.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:3000 { + import upstream_headers + import upstream_connection + } + } + + @codesync path /code-sync* + handle @codesync { + gitpod.cors_origin { + base_domain {$GITPOD_DOMAIN} + } + + import compression + + reverse_proxy server.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:3000 { + import upstream_headers + import upstream_connection + + flush_interval -1 + } + } + + @to_server path /auth/github/callback /auth /auth/* /apps /apps/* + handle @to_server { + import compression + + reverse_proxy server.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:3000 { + import upstream_headers + import upstream_connection + } + } + + handle { + reverse_proxy dashboard.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:3001 { + import upstream_headers + import upstream_connection + } + } + + handle_errors { + redir https://{$GITPOD_DOMAIN}/sorry/#Error%20{http.reverse_proxy.status_text} 302 + } +} + +# workspaces +https://*.*.{$GITPOD_DOMAIN} { + import enable_log + import security_headers + import remove_server_header + import ssl_configuration + import debug_headers + + @workspace_blobserve header_regexp host Host ^blobserve.ws(?P-[a-z0-9]+)?.{$GITPOD_DOMAIN} + handle @workspace_blobserve { + gitpod.cors_origin { + base_domain {$GITPOD_DOMAIN} + } + + reverse_proxy https://ws-proxy.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:9090 { + transport http { + tls_insecure_skip_verify + } + + import upstream_headers + + header_up X-WSProxy-Host {http.request.host} + + header_down -access-control-allow-origin + } + } + + @workspace_port header_regexp host Host ^(webview-|browser-|extensions-)?(?P[0-9]{2,5})-(?P[a-z0-9][0-9a-z\-]+).ws(?P-[a-z0-9]+)?.{$GITPOD_DOMAIN} + handle @workspace_port { + reverse_proxy ws-{re.host.workspaceID}-ports.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:{re.host.workspacePort} { + import upstream_headers + + header_up X-Gitpod-WorkspaceId {re.host.workspaceID} + header_up X-Gitpod-Port {re.host.workspacePort} + header_up X-WSProxy-Host {http.request.host} + } + } + + @workspace header_regexp host Host ^(webview-|browser-|extensions-)?(?P[a-z0-9][0-9a-z\-]+).ws(?P-[a-z0-9]+)?.{$GITPOD_DOMAIN} + handle @workspace { + reverse_proxy https://ws-proxy.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:9090 { + transport http { + tls_insecure_skip_verify + } + + import upstream_headers + + header_up X-Gitpod-WorkspaceId {re.host.workspaceID} + header_up X-WSProxy-Host {http.request.host} + } + } + + respond "Not found" 404 +} + +import /etc/caddy/vhosts/vhost.* diff --git a/components/proxy/conf/vhost.empty b/components/proxy/conf/vhost.empty new file mode 100644 index 00000000000000..437774fd7d5f76 --- /dev/null +++ b/components/proxy/conf/vhost.empty @@ -0,0 +1 @@ +# Placeholder to avoid errors loading files using a glob pattern diff --git a/components/proxy/plugins/workspace/cors_origin.go b/components/proxy/plugins/workspace/cors_origin.go new file mode 100644 index 00000000000000..8782bcbb1c6f19 --- /dev/null +++ b/components/proxy/plugins/workspace/cors_origin.go @@ -0,0 +1,122 @@ +// Copyright (c) 2021 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License-AGPL.txt in the project root for license information. + +package workspace + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "github.com/rs/cors" +) + +const ( + corsOriginModule = "gitpod.cors_origin" +) + +func init() { + caddy.RegisterModule(CorsOrigin{}) + httpcaddyfile.RegisterHandlerDirective(corsOriginModule, parseCorsOriginfile) +} + +// CorsOrigin implements an HTTP handler that generates a valid CORS Origin value +type CorsOrigin struct { + BaseDomain string `json:"base_domain,omitempty"` + Debug bool `json:"debug,omitempty"` +} + +// CaddyModule returns the Caddy module information. +func (CorsOrigin) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.handlers.gitpod_cors_origin", + New: func() caddy.Module { return new(CorsOrigin) }, + } +} + +var ( + allowedMethods = []string{http.MethodPost, http.MethodGet, http.MethodDelete, http.MethodOptions} + allowedHeaders = []string{"Accept", "Authorization", "Cache-Control", "Content-Type", "DNT", "Keep-Alive", "Origin", "User-Agent", + "If-Match", "If-Modified-Since", "If-None-Match", + "X-Requested-With", "X-Account-Type", "X-Client-Commit", "X-Client-Name", "X-Client-Version", "X-Execution-Id", "X-Machine-Id", "X-Machine-Session-Id", "X-User-Session-Id", + } + exposeHeaders = []string{"Authorization", "etag", "x-operation-id", "retry-after"} +) + +// ServeHTTP implements caddyhttp.MiddlewareHandler. +func (m CorsOrigin) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + c := cors.New(cors.Options{ + AllowedOrigins: []string{"*." + m.BaseDomain}, + AllowedMethods: allowedMethods, + AllowedHeaders: allowedHeaders, + ExposedHeaders: exposeHeaders, + AllowCredentials: true, + MaxAge: 60, + Debug: m.Debug, + }) + + c.ServeHTTP(w, r, + func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }, + ) + + return nil +} + +// UnmarshalCaddyfile implements Caddyfile.Unmarshaler. +func (m *CorsOrigin) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + if !d.Next() { + return d.Err("expected token following filter") + } + + for d.NextBlock(0) { + key := d.Val() + var value string + d.Args(&value) + if d.NextArg() { + return d.ArgErr() + } + + switch key { + case "base_domain": + m.BaseDomain = value + case "debug": + b, err := strconv.ParseBool(value) + if err != nil { + return d.Errf("invalid boolean value for subdirective debug '%s'", value) + } + + m.Debug = b + default: + return d.Errf("unrecognized subdirective '%s'", value) + } + } + + if m.BaseDomain == "" { + return fmt.Errorf("Please configure the base_domain subdirective") + } + + return nil +} + +func parseCorsOriginfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { + m := new(CorsOrigin) + err := m.UnmarshalCaddyfile(h.Dispenser) + if err != nil { + return nil, err + } + + return m, nil +} + +// Interface guards +var ( + _ caddyhttp.MiddlewareHandler = (*CorsOrigin)(nil) + _ caddyfile.Unmarshaler = (*CorsOrigin)(nil) +) diff --git a/components/proxy/plugins/workspace/download_workspace.go b/components/proxy/plugins/workspace/download_workspace.go new file mode 100644 index 00000000000000..116b41da8aaf01 --- /dev/null +++ b/components/proxy/plugins/workspace/download_workspace.go @@ -0,0 +1,131 @@ +// Copyright (c) 2021 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License-AGPL.txt in the project root for license information. + +package workspace + +import ( + "fmt" + "io" + "net/http" + "time" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +const ( + workspaceDownloadModule = "gitpod.workspace_download" +) + +func init() { + caddy.RegisterModule(Download{}) + httpcaddyfile.RegisterHandlerDirective(workspaceDownloadModule, parseCaddyfile) +} + +// Download implements an HTTP handler that extracts gitpod headers +type Download struct { + Service string `json:"service,omitempty"` +} + +// CaddyModule returns the Caddy module information. +func (Download) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.handlers.gitpod_workspace_download", + New: func() caddy.Module { return new(Download) }, + } +} + +// ServeHTTP implements caddyhttp.MiddlewareHandler. +func (m Download) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + + query := r.URL.RawQuery + if query != "" { + query = "?" + query + } + + url := fmt.Sprintf("%v%v%v", m.Service, r.URL.Path, query) + client := http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("Server Error: cannot download token OTS") + } + + // pass browser headers + // TODO (aledbf): check if it's possible to narrow the list + for k, vv := range r.Header { + for _, v := range vv { + req.Header.Add(k, v) + } + } + + // override content-type + req.Header.Set("Content-Type", "*/*") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("Server Error: cannot download token OTS") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Bad Request: /workspace-download/get returned with code %v", resp.StatusCode) + } + + redirectURL, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("Server error: cannot obtain redirect URL") + } + + repl.Set("http."+workspaceDownloadModule+"_url", string(redirectURL)) + + return next.ServeHTTP(w, r) +} + +// UnmarshalCaddyfile implements Caddyfile.Unmarshaler. +func (m *Download) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + if !d.Next() { + return d.Err("expected token following filter") + } + + for d.NextBlock(0) { + key := d.Val() + var value string + d.Args(&value) + if d.NextArg() { + return d.ArgErr() + } + + switch key { + case "service": + m.Service = value + default: + return d.Errf("unrecognized subdirective '%s'", d.Val()) + } + } + + if m.Service == "" { + return fmt.Errorf("Please configure the service subdirective") + } + + return nil +} + +func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { + m := new(Download) + err := m.UnmarshalCaddyfile(h.Dispenser) + if err != nil { + return nil, err + } + + return m, nil +} + +// Interface guards +var ( + _ caddyhttp.MiddlewareHandler = (*Download)(nil) + _ caddyfile.Unmarshaler = (*Download)(nil) +) diff --git a/components/proxy/plugins/workspace/go.mod b/components/proxy/plugins/workspace/go.mod new file mode 100644 index 00000000000000..26c98625bf13dc --- /dev/null +++ b/components/proxy/plugins/workspace/go.mod @@ -0,0 +1,8 @@ +module github.com/gitpod-io/gitpod/proxy/plugins/workspace + +go 1.16 + +require ( + github.com/caddyserver/caddy/v2 v2.4.0 + github.com/rs/cors v1.6.0 +)