diff --git a/deploy/role.yaml b/deploy/role.yaml index 7a4b184df..0220fcc15 100644 --- a/deploy/role.yaml +++ b/deploy/role.yaml @@ -84,3 +84,15 @@ rules: - pods/exec verbs: - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: rpaas-session-tickets-rotator +rules: +- apiGroups: [""] + resources: + - secrets + verbs: + - get + - patch diff --git a/deploy/role_binding.yaml b/deploy/role_binding.yaml index e50499a7b..2b14115ef 100644 --- a/deploy/role_binding.yaml +++ b/deploy/role_binding.yaml @@ -23,3 +23,17 @@ roleRef: kind: ClusterRole name: rpaas-cache-syncer apiGroup: rbac.authorization.k8s.io +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: rpaas-session-tickets-rotator +subjects: +- kind: ServiceAccount + name: rpaas-session-tickets-rotator + namespace: rpaas-operator-integration +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: rpaas-session-tickets-rotator +--- diff --git a/deploy/service_account.yaml b/deploy/service_account.yaml index aa5c5f3fd..c0bfe0a7b 100644 --- a/deploy/service_account.yaml +++ b/deploy/service_account.yaml @@ -7,3 +7,9 @@ apiVersion: v1 kind: ServiceAccount metadata: name: rpaas-cache-snapshot +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: rpaas-session-tickets-rotator +--- diff --git a/internal/pkg/rpaas/nginx/configuration_render.go b/internal/pkg/rpaas/nginx/configuration_render.go index ee07a704a..c1aadaa39 100644 --- a/internal/pkg/rpaas/nginx/configuration_render.go +++ b/internal/pkg/rpaas/nginx/configuration_render.go @@ -156,21 +156,57 @@ func k8sQuantityToNginx(quantity *resource.Quantity) string { return strconv.Itoa(int(bytesN)) } +func tlsSessionTicketEnabled(instance *v1alpha1.RpaasInstance) bool { + return instance != nil && + instance.Spec.TLSSessionResumption != nil && + instance.Spec.TLSSessionResumption.SessionTicket != nil +} + +func tlsSessionTicketKeys(instance *v1alpha1.RpaasInstance) int { + if !tlsSessionTicketEnabled(instance) { + return 0 + } + + return int(instance.Spec.TLSSessionResumption.SessionTicket.KeepLastKeys) + 1 +} + +func tlsSessionTicketTimeout(instance *v1alpha1.RpaasInstance) int { + nkeys := tlsSessionTicketKeys(instance) + + keyRotationInterval := v1alpha1.DefaultSessionTicketKeyRotationInteval + if tlsSessionTicketEnabled(instance) && + instance.Spec.TLSSessionResumption.SessionTicket.KeyRotationInterval != uint32(0) { + keyRotationInterval = instance.Spec.TLSSessionResumption.SessionTicket.KeyRotationInterval + } + + return nkeys * int(keyRotationInterval) +} + var templateFuncs = template.FuncMap(map[string]interface{}{ - "boolValue": v1alpha1.BoolValue, - "buildLocationKey": buildLocationKey, - "hasRootPath": hasRootPath, - "toLower": strings.ToLower, - "toUpper": strings.ToUpper, - "managePort": managePort, - "httpPort": httpPort, - "httpsPort": httpsPort, - "purgeLocationMatch": purgeLocationMatch, - "vtsLocationMatch": vtsLocationMatch, - "contains": strings.Contains, - "hasPrefix": strings.HasPrefix, - "hasSuffix": strings.HasSuffix, - "k8sQuantityToNginx": k8sQuantityToNginx, + "boolValue": v1alpha1.BoolValue, + "buildLocationKey": buildLocationKey, + "hasRootPath": hasRootPath, + "toLower": strings.ToLower, + "toUpper": strings.ToUpper, + "managePort": managePort, + "httpPort": httpPort, + "httpsPort": httpsPort, + "purgeLocationMatch": purgeLocationMatch, + "vtsLocationMatch": vtsLocationMatch, + "contains": strings.Contains, + "hasPrefix": strings.HasPrefix, + "hasSuffix": strings.HasSuffix, + "k8sQuantityToNginx": k8sQuantityToNginx, + "tlsSessionTicketEnabled": tlsSessionTicketEnabled, + "tlsSessionTicketKeys": tlsSessionTicketKeys, + "tlsSessionTicketTimeout": tlsSessionTicketTimeout, + "iterate": func(n int) []int { + v := make([]int, n) + for i := 0; i < n; i++ { + v[i] = i + } + return v + }, }) var defaultMainTemplate = template.Must(template.New("main"). @@ -245,6 +281,21 @@ http { {{- end }} {{- end}} + {{- if tlsSessionTicketEnabled $instance }} + {{- with $instance.Spec.TLSSessionResumption.SessionTicket }}{{ "\n" }} + ssl_session_cache off; + + ssl_session_tickets on; + {{- range $index, $_ := (iterate (tlsSessionTicketKeys $instance)) }} + ssl_session_ticket_key tickets/ticket.{{ $index }}.key; + {{- end }} + + {{- with (tlsSessionTicketTimeout $instance) }} + ssl_session_timeout {{ . }}m; + {{- end }} + {{- end }} + {{- end }} + {{- range $index, $bind := $instance.Spec.Binds }} {{- if eq $index 0 }} @@ -283,6 +334,16 @@ http { } init_worker_by_lua_block { + {{- if tlsSessionTicketEnabled $instance }} + {{- with $instance.Spec.TLSSessionResumption.SessionTicket }} + local rpaasv2_session_ticket_reloader = require('tsuru.rpaasv2.tls.session_ticket_reloader'):new({ + ticket_file = '/etc/nginx/tickets/ticket.0.key', + retain_last_keys = {{ tlsSessionTicketKeys $instance }}, + }) + rpaasv2_session_ticket_reloader:start_worker() + {{- end }} + {{- end }} + {{- template "lua-worker" . }} } diff --git a/internal/pkg/rpaas/nginx/configuration_render_test.go b/internal/pkg/rpaas/nginx/configuration_render_test.go index cc4bae3d3..a65af2de6 100644 --- a/internal/pkg/rpaas/nginx/configuration_render_test.go +++ b/internal/pkg/rpaas/nginx/configuration_render_test.go @@ -451,6 +451,66 @@ func TestRpaasConfigurationRenderer_Render(t *testing.T) { assert.Regexp(t, `listen 20003;`, result) }, }, + { + name: "with TLS session tickets enabled (using default values)", + data: ConfigurationData{ + Config: &v1alpha1.NginxConfig{}, + Instance: &v1alpha1.RpaasInstance{ + Spec: v1alpha1.RpaasInstanceSpec{ + TLSSessionResumption: &v1alpha1.TLSSessionResumption{ + SessionTicket: &v1alpha1.TLSSessionTicket{}, + }, + }, + }, + }, + assertion: func(t *testing.T, result string) { + assert.Regexp(t, `ssl_session_cache\s+off;`, result) + assert.Regexp(t, `ssl_session_tickets\s+on;`, result) + assert.Regexp(t, `ssl_session_ticket_key\s+tickets/ticket.0.key;`, result) + assert.Regexp(t, `ssl_session_timeout\s+60m;`, result) + assert.Regexp(t, `init_worker_by_lua_block \{\n* +\s+local rpaasv2_session_ticket_reloader = require\('tsuru.rpaasv2.tls.session_ticket_reloader'\):new\(\{ +\s+ticket_file = '/etc/nginx/tickets/ticket.0.key', +\s+retain_last_keys = 1, +\s+\}\) +\s+rpaasv2_session_ticket_reloader:start_worker\(\) +\s+\}`, result) + }, + }, + { + name: "with TLS session tickets enabled and custom values", + data: ConfigurationData{ + Config: &v1alpha1.NginxConfig{}, + Instance: &v1alpha1.RpaasInstance{ + Spec: v1alpha1.RpaasInstanceSpec{ + TLSSessionResumption: &v1alpha1.TLSSessionResumption{ + SessionTicket: &v1alpha1.TLSSessionTicket{ + KeepLastKeys: uint32(5), + KeyRotationInterval: uint32(60 * 24), // daily + }, + }, + }, + }, + }, + assertion: func(t *testing.T, result string) { + assert.Regexp(t, `ssl_session_cache\s+off;`, result) + assert.Regexp(t, `ssl_session_tickets\s+on;`, result) + assert.Regexp(t, `ssl_session_ticket_key\s+tickets/ticket.0.key;`, result) + assert.Regexp(t, `ssl_session_ticket_key\s+tickets/ticket.1.key;`, result) + assert.Regexp(t, `ssl_session_ticket_key\s+tickets/ticket.2.key;`, result) + assert.Regexp(t, `ssl_session_ticket_key\s+tickets/ticket.3.key;`, result) + assert.Regexp(t, `ssl_session_ticket_key\s+tickets/ticket.4.key;`, result) + assert.Regexp(t, `ssl_session_ticket_key\s+tickets/ticket.5.key;`, result) + assert.Regexp(t, `ssl_session_timeout\s+8640m;`, result) + assert.Regexp(t, `init_worker_by_lua_block \{\n* +\s+local rpaasv2_session_ticket_reloader = require\('tsuru.rpaasv2.tls.session_ticket_reloader'\):new\(\{ +\s+ticket_file = '/etc/nginx/tickets/ticket.0.key', +\s+retain_last_keys = 6, +\s+\}\) +\s+rpaasv2_session_ticket_reloader:start_worker\(\) +\s+\}`, result) + }, + }, } for _, tt := range tests { diff --git a/lualib/tsuru/rpaasv2/tls/session_ticket.lua b/lualib/tsuru/rpaasv2/tls/session_ticket.lua new file mode 100644 index 000000000..41890224b --- /dev/null +++ b/lualib/tsuru/rpaasv2/tls/session_ticket.lua @@ -0,0 +1,78 @@ +-- Copyright 2020 tsuru authors. All rights reserved. +-- Use of this source code is governed by a BSD-style +-- license that can be found in the LICENSE file. +-- + +local _M = {} + +local ffi = require('ffi') +local C = ffi.C +local ffi_str = ffi.string + +local base = require('resty.core.base') +local table = require('table') + +local get_string_buf = base.get_string_buf +local get_errmsg_ptr = base.get_errmsg_ptr +local void_ptr_type = ffi.typeof('void*') +local void_ptr_ptr_type = ffi.typeof('void**') +local ptr_size = ffi.sizeof(void_ptr_type) + +ffi.cdef[[ +int ngx_http_lua_ffi_get_ssl_ctx_count(void); + +int ngx_http_lua_ffi_get_ssl_ctx_list(void **buf); + +int ngx_http_lua_ffi_update_ticket_encryption_key(void *ctx, const unsigned char *key, unsigned int nkeys, const unsigned int key_length, char **err); +]] + +local function get_ssl_contexts() + local n = C.ngx_http_lua_ffi_get_ssl_ctx_count() + if n < 0 then + return nil, 'ssl context cannot be negative' + end + + if n == 0 then + return nil, nil + end + + local buffer = ffi.cast(void_ptr_ptr_type, get_string_buf(ptr_size * n)) + local rc = ffi.C.ngx_http_lua_ffi_get_ssl_ctx_list(buffer) + if rc ~= 0 then -- not NGX_OK + return nil, 'cannot get the ssl contexts' + end + + local ctxs = table.new(n, 0) + for i = 1, n do + ctxs[i] = buffer[i - 1] + end + + return ctxs, nil +end + +function _M.update_ticket_encryption_key(key, nkeys) + local ssl_contexts, err = get_ssl_contexts() + if err then + return err + end + + if not ssl_contexts or #ssl_contexts == 0 then + return 'no ssl ctx set' + end + + if #key ~= 48 and #key ~= 80 then + return 'ssl ticket key must either have 48 or 80 bytes' + end + + for _, ctx in ipairs(ssl_contexts) do + local errmsg = get_errmsg_ptr() + local rc = C.ngx_http_lua_ffi_update_ticket_encryption_key(ctx, key, nkeys, #key, errmsg) + if rc ~= 0 then -- not NGX_OK + return 'failed to update the key into OpenSSL context: ' .. ffi_str(errmsg[0]) + end + end + + return nil +end + +return _M diff --git a/lualib/tsuru/rpaasv2/tls/session_ticket_reloader.lua b/lualib/tsuru/rpaasv2/tls/session_ticket_reloader.lua new file mode 100644 index 000000000..08f498180 --- /dev/null +++ b/lualib/tsuru/rpaasv2/tls/session_ticket_reloader.lua @@ -0,0 +1,132 @@ +-- Copyright 2020 tsuru authors. All rights reserved. +-- Use of this source code is governed by a BSD-style +-- license that can be found in the LICENSE file. +-- + +local _M = {} +local _m = {} + +local inotify = require('inotify') +local ngx = require('ngx') + +local rpaasv2_session_ticket = require('tsuru.rpaasv2.tls.session_ticket') + +local options + +local unix_path_pattern = [[^(.+)/(.+)$]] + +local function dir_name(path) + return path:gsub(unix_path_pattern, '%1') +end + +local function base_name(path) + return path:gsub(unix_path_pattern, '%2') +end + +local function read_file(filename) + local file = io.open(filename, 'rb') + if not file then + return '', 'failed to open the file ' .. filename + end + + local content = file:read('*a') + file:close() + + return content, nil +end + +local function session_ticket_reloader(premature, self) + if not self.handle then + ngx.log(ngx.ERR, "inotify handle not provided") + return + end + + if premature then + ngx.log(ngx.DEBUG, 'cleaning up the session ticket reloader') + self.handle:close() + return + end + + for event in self.handle:events() do + if event.name == self.ticket_base_name or event.name == '..data' then + self:update_current_encryption_key() + end + end +end + +function _M:new(opts) + if type(opts) ~= 'table' then + return nil, 'opts must be a table' + end + + local m = setmetatable({}, { __index = _m }) + + m.ticket_file = opts.ticket_file + if type(m.ticket_file) ~= 'string' or not m.ticket_file then + return nil, 'session ticket encryption key file (ticket_file) cannot be either non-string or empty' + end + + m.ticket_dir_name = dir_name(m.ticket_file) + m.ticket_base_name = base_name(m.ticket_file) + + m.retain_last_keys = opts.retain_last_keys or 1 + if type(m.retain_last_keys) ~= 'number' or m.retain_last_keys < 1 then + return nil, 'number of keys (retain_last_keys) must be an integer number (greater than zero)' + end + + m.sync_interval = opts.sync_interval or 5 + if type(m.sync_interval) ~= 'number' or m.sync_interval < 1 then + return nil, 'sync interval (sync_interval) must be a integer number (greater than zero seconds)' + end + + return m, nil +end + +function _m:start_worker() + local worker_id = ngx.worker.id() + if worker_id ~= 0 then -- not the first nginx worker + ngx.log(ngx.DEBUG, 'skipping execution of this worker: ', worker_id) + return + end + + ngx.log(ngx.NOTICE, 'Running TLS session ticket encryption key reloader') + ngx.log(ngx.NOTICE, 'Watching for changes of ', self.ticket_base_name,' file in ', self.ticket_dir_name, ' directory every ', self.sync_interval, 's') + + self.handle = inotify.init({ blocking = false }) + self.handle:addwatch(self.ticket_dir_name, bit.bor(inotify.IN_CREATE, inotify.IN_MODIFY, inotify.IN_MOVED_TO)) + + local ok, err = ngx.timer.every(self.sync_interval, session_ticket_reloader, self) + if not ok then + ngx.log(ngx.ERR, 'failed to create timer: ', err) + end +end + +function _m:update_current_encryption_key() + local key, err = read_file(self.ticket_file) + if err then + ngx.log(ngx.ERR, 'failed to read the current token: ', err) + return false, err + end + + local new_key_digest = ngx.md5(key) + ngx.log(ngx.DEBUG, 'New key MD5 digest: ', new_key_digest) + ngx.log(ngx.DEBUG, 'Current key MD5 digest: ', self.current_key_digest) + + if self.current_key_digest == new_key_digest then + ngx.log(ngx.NOTICE, 'nothing to update due to the new key equals to the current one') + return false, nil + end + + local err = rpaasv2_session_ticket.update_ticket_encryption_key(key, self.retain_last_keys) + if err then + ngx.log(ngx.ERR, 'failed to update the new encryption key: ', err) + return false, err + end + + ngx.log(ngx.NOTICE, 'New encryption key (', new_key_digest ,') updated') + self.current_key_digest = new_key_digest + + return true, nil +end + +return _M diff --git a/pkg/apis/extensions/v1alpha1/rpaasinstance_types.go b/pkg/apis/extensions/v1alpha1/rpaasinstance_types.go index a8db1369d..536c27b17 100644 --- a/pkg/apis/extensions/v1alpha1/rpaasinstance_types.go +++ b/pkg/apis/extensions/v1alpha1/rpaasinstance_types.go @@ -79,6 +79,12 @@ type RpaasInstanceSpec struct { // some event happens to nginx container. // +optional Lifecycle *nginxv1alpha1.NginxLifecycle `json:"lifecycle,omitempty"` + + // TLSSessionResumption configures the instance to support session resumption + // using either session tickets or session ID (in the future). Defaults to + // disabled. + // +optional + TLSSessionResumption *TLSSessionResumption `json:"tlsSessionResumption,omitempty"` } type Bind struct { @@ -162,6 +168,55 @@ type RpaasInstanceAutoscaleSpec struct { TargetMemoryUtilizationPercentage *int32 `json:"targetMemoryUtilizationPercentage,omitempty"` } +type TLSSessionResumption struct { + // SessionTicket defines the parameters to set the TLS session tickets. + // +optional + SessionTicket *TLSSessionTicket `json:"sessionTicket,omitempty"` +} + +const ( + // DefaultSessionTicketKeyRotationInteval holds the default time interval to + // rotate the session tickets: 1 hour. + DefaultSessionTicketKeyRotationInteval uint32 = 60 +) + +type SessionTicketKeyLength uint16 + +const ( + // SessionTicketKeyLength48 represents 48 bytes of session ticket key length. + SessionTicketKeyLength48 = SessionTicketKeyLength(48) + + // SessionTicketKeyLength80 represents 80 bytes of session ticket key length. + SessionTicketKeyLength80 = SessionTicketKeyLength(80) + + // DefaultSessionTicketKeyLength holds the default session ticket key length. + DefaultSessionTicketKeyLength = SessionTicketKeyLength48 +) + +type TLSSessionTicket struct { + // KeepLastKeys defines how many session ticket encryption keys should be + // kept in addition to the current one. Zero means no old encryption keys. + // Defaults to zero. + // +optional + KeepLastKeys uint32 `json:"keepLastKeys,omitempty"` + + // KeyRotationInterval defines the time interval, in minutes, that a + // key rotation job should occurs. Defaults to 60 minutes (an hour). + // +optional + KeyRotationInterval uint32 `json:"keyRotationInterval,omitempty"` + + // KeyLength defines the length of bytes for a session tickets. Should be + // either 48 or 80 bytes. Defaults to 48 bytes. + // +optional + KeyLength SessionTicketKeyLength `json:"keyLength,omitempty"` + + // Image is the container image name used to execute the session ticket + // rotation job. It requires either "bash", "base64", "openssl" and "kubectl" + // programs be installed into. Defaults to "bitnami/kubectl:latest". + // +optional + Image string `json:"image,omitempty"` +} + func init() { SchemeBuilder.Register(&RpaasInstance{}, &RpaasInstanceList{}) } diff --git a/pkg/apis/extensions/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/extensions/v1alpha1/zz_generated.deepcopy.go index 4a7700781..8add3d713 100644 --- a/pkg/apis/extensions/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/extensions/v1alpha1/zz_generated.deepcopy.go @@ -427,6 +427,11 @@ func (in *RpaasInstanceSpec) DeepCopyInto(out *RpaasInstanceSpec) { *out = new(nginxv1alpha1.NginxLifecycle) (*in).DeepCopyInto(*out) } + if in.TLSSessionResumption != nil { + in, out := &in.TLSSessionResumption, &out.TLSSessionResumption + *out = new(TLSSessionResumption) + (*in).DeepCopyInto(*out) + } return } @@ -636,6 +641,43 @@ func (in *RpaasPortAllocationStatus) DeepCopy() *RpaasPortAllocationStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSessionResumption) DeepCopyInto(out *TLSSessionResumption) { + *out = *in + if in.SessionTicket != nil { + in, out := &in.SessionTicket, &out.SessionTicket + *out = new(TLSSessionTicket) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSessionResumption. +func (in *TLSSessionResumption) DeepCopy() *TLSSessionResumption { + if in == nil { + return nil + } + out := new(TLSSessionResumption) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSessionTicket) DeepCopyInto(out *TLSSessionTicket) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSessionTicket. +func (in *TLSSessionTicket) DeepCopy() *TLSSessionTicket { + if in == nil { + return nil + } + out := new(TLSSessionTicket) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Value) DeepCopyInto(out *Value) { *out = *in diff --git a/pkg/controller/rpaasinstance/rpaasinstance_controller.go b/pkg/controller/rpaasinstance/rpaasinstance_controller.go index 2ab00c9b5..b1adf7d49 100644 --- a/pkg/controller/rpaasinstance/rpaasinstance_controller.go +++ b/pkg/controller/rpaasinstance/rpaasinstance_controller.go @@ -7,6 +7,7 @@ package rpaasinstance import ( "bytes" "context" + "crypto/rand" "crypto/sha256" "fmt" "reflect" @@ -56,6 +57,9 @@ const ( rsyncCommandPodToPVC = "rsync -avz --recursive --delete --temp-dir=${CACHE_SNAPSHOT_MOUNTPOINT}/temp ${CACHE_PATH}/nginx ${CACHE_SNAPSHOT_MOUNTPOINT}" rsyncCommandPVCToPod = "rsync -avz --recursive --delete --temp-dir=${CACHE_PATH}/nginx_tmp ${CACHE_SNAPSHOT_MOUNTPOINT}/nginx ${CACHE_PATH}" + + sessionTicketsSecretSuffix = "-session-tickets" + sessionTicketsCronJobSuffix = "-session-tickets" ) var ( @@ -82,6 +86,79 @@ mkdir -p ${CACHE_SNAPSHOT_MOUNTPOINT}/nginx; mkdir -p ${CACHE_PATH}/nginx_tmp; ${POD_CMD} `} + + defaultRotateTLSSessionTicketsImage = "bitnami/kubectl:latest" + + sessionTicketsVolumeName = "tls-session-tickets" + sessionTicketsVolumeMountPath = "/etc/nginx/tickets" + + rotateTLSSessionTicketsServiceAccountName = "rpaas-session-tickets-rotator" + rotateTLSSessionTicketsVolumeName = "tls-session-tickets-script" + rotateTLSSessionTicketsScriptDir = "/var/run/rpaasv2" + rotateTLSSessionTicketsScriptFilename = "tls_session_tickets_rotate.sh" + rotateTLSSessionTicketsScriptPath = fmt.Sprintf("%s/%s", rotateTLSSessionTicketsScriptDir, rotateTLSSessionTicketsScriptFilename) + rotateTLSSessionTicketsScript = `#!/bin/bash +set -euf -o pipefail + +KUBECTL=${KUBECTL:-kubectl} +OPENSSL=${OPENSSL:-openssl} +BASE64=${BASE64:-base64} + +SESSION_TICKET_KEY_LENGTH=${SESSION_TICKET_KEY_LENGTH:?missing session ticket key length} +SESSION_TICKET_KEYS=${SESSION_TICKET_KEYS:?missing number of session ticket keys} + +SECRET_NAME=${SECRET_NAME:?missing Secret's name} +SECRET_NAMESPACE=${SECRET_NAMESPACE:?missing Secret's namespace} + +function validate_key_length() { + case ${SESSION_TICKET_KEY_LENGTH} in + 48|80) + ;; + *) + echo "Nginx only has support to tickets with either 48 or 80 bytes, got ${SESSION_TICKET_KEY_LENGTH} bytes." &> /dev/stderr + exit 1 + esac +} + +function generate_key() { + base64 -w0 <(${OPENSSL} rand ${SESSION_TICKET_KEY_LENGTH}) +} + +function json_merge_patch_payload() { + local key=${1} + + local others='' + for (( i = ${SESSION_TICKET_KEYS} - 1; i >= 1; i-- )) do + others+=$(printf '{"op": "copy", "from": "/data/ticket.%d.key", "path": "/data/ticket.%d.key"},\n' $(( ${i} - 1 )) ${i}) + done + + cat <<-EOL +[ + ${others} + { + "op": "replace", + "path": "/data/ticket.0.key", + "value": "${key}" + } +] +EOL +} + +function rotate_session_tickets() { + local key=${1} + + ${KUBECTL} patch secrets ${SECRET_NAME} --namespace ${SECRET_NAMESPACE} --type=json \ + --patch="$(json_merge_patch_payload ${key})" +} + +function main() { + echo "Starting rotation of TLS session tickets within Secret (${SECRET_NAMESPACE}/${SECRET_NAME})..." + rotate_session_tickets $(generate_key) + echo "TLS session tickets successfully updated." +} + +main $@ +` ) var log = logf.Log.WithName("controller_rpaasinstance") @@ -217,6 +294,7 @@ func (r *ReconcileRpaasInstance) Reconcile(request reconcile.Request) (reconcile if err != nil { return reconcile.Result{}, err } + configMap := newConfigMap(instance, rendered) err = r.reconcileConfigMap(ctx, configMap) if err != nil { @@ -232,8 +310,11 @@ func (r *ReconcileRpaasInstance) Reconcile(request reconcile.Request) (reconcile } } - nginx := newNginx(instance, plan, configMap) + if err = r.reconcileTLSSessionResumption(ctx, instance); err != nil { + return reconcile.Result{}, err + } + nginx := newNginx(instance, plan, configMap) if err = r.reconcileNginx(ctx, nginx); err != nil { return reconcile.Result{}, err } @@ -354,6 +435,279 @@ func (r *ReconcileRpaasInstance) listDefaultFlavors(ctx context.Context, instanc return result, nil } +func (r *ReconcileRpaasInstance) reconcileTLSSessionResumption(ctx context.Context, instance *v1alpha1.RpaasInstance) error { + if err := r.reconcileSecretForSessionTickets(ctx, instance); err != nil { + return err + } + + return r.reconcileCronJobForSessionTickets(ctx, instance) +} + +func (r *ReconcileRpaasInstance) reconcileSecretForSessionTickets(ctx context.Context, instance *v1alpha1.RpaasInstance) error { + enabled := isTLSSessionTicketEnabled(instance) + + newSecret, err := newSecretForTLSSessionTickets(instance) + if err != nil { + return err + } + + var secret corev1.Secret + secretName := types.NamespacedName{ + Name: newSecret.Name, + Namespace: newSecret.Namespace, + } + err = r.client.Get(ctx, secretName, &secret) + if err != nil && k8sErrors.IsNotFound(err) { + if !enabled { + return nil + } + + return r.client.Create(ctx, newSecret) + } + + if err != nil { + return err + } + + if !enabled { + return r.client.Delete(ctx, &secret) + } + + newData := newSessionTicketData(secret.Data, newSecret.Data) + if !reflect.DeepEqual(newData, secret.Data) { + secret.Data = newData + return r.client.Update(ctx, &secret) + } + + return nil +} + +func (r *ReconcileRpaasInstance) reconcileCronJobForSessionTickets(ctx context.Context, instance *v1alpha1.RpaasInstance) error { + enabled := isTLSSessionTicketEnabled(instance) + + newCronJob := newCronJobForSessionTickets(instance) + + var cj batchv1beta1.CronJob + cjName := types.NamespacedName{ + Name: newCronJob.Name, + Namespace: newCronJob.Namespace, + } + err := r.client.Get(ctx, cjName, &cj) + if err != nil && k8sErrors.IsNotFound(err) { + if !enabled { + return nil + } + + return r.client.Create(ctx, newCronJob) + } + + if err != nil { + return err + } + + if !enabled { + return r.client.Delete(ctx, &cj) + } + + if reflect.DeepEqual(newCronJob.Spec, cj.Spec) { + return nil + } + + newCronJob.ResourceVersion = cj.ResourceVersion + return r.client.Update(ctx, newCronJob) +} + +func newCronJobForSessionTickets(instance *v1alpha1.RpaasInstance) *batchv1beta1.CronJob { + enabled := isTLSSessionTicketEnabled(instance) + + keyLength := v1alpha1.DefaultSessionTicketKeyLength + if enabled && instance.Spec.TLSSessionResumption.SessionTicket.KeyLength != 0 { + keyLength = instance.Spec.TLSSessionResumption.SessionTicket.KeyLength + } + + rotationInterval := v1alpha1.DefaultSessionTicketKeyRotationInteval + if enabled && instance.Spec.TLSSessionResumption.SessionTicket.KeyRotationInterval != 0 { + rotationInterval = instance.Spec.TLSSessionResumption.SessionTicket.KeyRotationInterval + } + + image := defaultCacheSnapshotCronImage + if enabled && instance.Spec.TLSSessionResumption.SessionTicket.Image != "" { + image = instance.Spec.TLSSessionResumption.SessionTicket.Image + } + + return &batchv1beta1.CronJob{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "batch/v1beta1", + Kind: "CronJob", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s%s", instance.Name, sessionTicketsCronJobSuffix), + Namespace: instance.Namespace, + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(instance, schema.GroupVersionKind{ + Group: v1alpha1.SchemeGroupVersion.Group, + Version: v1alpha1.SchemeGroupVersion.Version, + Kind: "RpaasInstance", + }), + }, + Labels: labelsForRpaasInstance(instance), + }, + Spec: batchv1beta1.CronJobSpec{ + Schedule: minutesIntervalToSchedule(rotationInterval), + JobTemplate: batchv1beta1.JobTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + Labels: labelsForRpaasInstance(instance), + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + rotateTLSSessionTicketsScriptFilename: rotateTLSSessionTicketsScript, + }, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: rotateTLSSessionTicketsServiceAccountName, + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "session-ticket-rotator", + Image: image, + Command: []string{"/bin/bash"}, + Args: []string{rotateTLSSessionTicketsScriptPath}, + Env: []corev1.EnvVar{ + { + Name: "SECRET_NAME", + Value: secretNameForTLSSessionTickets(instance), + }, + { + Name: "SECRET_NAMESPACE", + Value: instance.Namespace, + }, + { + Name: "SESSION_TICKET_KEY_LENGTH", + Value: fmt.Sprint(keyLength), + }, + { + Name: "SESSION_TICKET_KEYS", + Value: fmt.Sprint(tlsSessionTicketKeys(instance)), + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: rotateTLSSessionTicketsVolumeName, + MountPath: rotateTLSSessionTicketsScriptPath, + SubPath: rotateTLSSessionTicketsScriptFilename, + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: rotateTLSSessionTicketsVolumeName, + VolumeSource: corev1.VolumeSource{ + DownwardAPI: &corev1.DownwardAPIVolumeSource{ + Items: []corev1.DownwardAPIVolumeFile{ + { + Path: rotateTLSSessionTicketsScriptFilename, + FieldRef: &corev1.ObjectFieldSelector{ + FieldPath: fmt.Sprintf("metadata.annotations['%s']", rotateTLSSessionTicketsScriptFilename), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func newSecretForTLSSessionTickets(instance *v1alpha1.RpaasInstance) (*corev1.Secret, error) { + keyLength := v1alpha1.DefaultSessionTicketKeyLength + if isTLSSessionTicketEnabled(instance) && instance.Spec.TLSSessionResumption.SessionTicket.KeyLength != 0 { + keyLength = instance.Spec.TLSSessionResumption.SessionTicket.KeyLength + } + + data := make(map[string][]byte) + for i := 0; i < tlsSessionTicketKeys(instance); i++ { + key, err := generateSessionTicket(keyLength) + if err != nil { + return nil, err + } + + data[fmt.Sprintf("ticket.%d.key", i)] = key + } + + return &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Secret", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: secretNameForTLSSessionTickets(instance), + Namespace: instance.Namespace, + Labels: labelsForRpaasInstance(instance), + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(instance, schema.GroupVersionKind{ + Group: v1alpha1.SchemeGroupVersion.Group, + Version: v1alpha1.SchemeGroupVersion.Version, + Kind: "RpaasInstance", + }), + }, + }, + Data: data, + }, nil +} + +func isTLSSessionTicketEnabled(instance *v1alpha1.RpaasInstance) bool { + return instance.Spec.TLSSessionResumption != nil && instance.Spec.TLSSessionResumption.SessionTicket != nil +} + +func tlsSessionTicketKeys(instance *v1alpha1.RpaasInstance) int { + var nkeys int + if isTLSSessionTicketEnabled(instance) { + nkeys = int(instance.Spec.TLSSessionResumption.SessionTicket.KeepLastKeys) + } + return nkeys + 1 +} + +func secretNameForTLSSessionTickets(instance *v1alpha1.RpaasInstance) string { + return fmt.Sprintf("%s%s", instance.Name, sessionTicketsSecretSuffix) +} + +func generateSessionTicket(keyLength v1alpha1.SessionTicketKeyLength) ([]byte, error) { + buffer := make([]byte, int(keyLength)) + _, err := rand.Read(buffer) + if err != nil { + return nil, err + } + return buffer, nil +} + +func newSessionTicketData(old, new map[string][]byte) map[string][]byte { + newest := make(map[string][]byte) + for k, v := range new { + if vv, found := old[k]; found { + newest[k] = vv + continue + } + newest[k] = v + } + + for k, v := range old { + if _, found := new[k]; found { + newest[k] = v + } + } + + return newest +} + func (r *ReconcileRpaasInstance) reconcileHPA(ctx context.Context, instance *v1alpha1.RpaasInstance, nginx *nginxv1alpha1.Nginx) error { logger := log.WithName("reconcileHPA"). WithValues("RpaasInstance", types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}). @@ -763,6 +1117,23 @@ func newNginx(instance *v1alpha1.RpaasInstance, plan *v1alpha1.RpaasPlan, config }, } + if isTLSSessionTicketEnabled(instance) { + n.Spec.PodTemplate.Volumes = append(n.Spec.PodTemplate.Volumes, corev1.Volume{ + Name: sessionTicketsVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: secretNameForTLSSessionTickets(instance), + }, + }, + }) + + n.Spec.PodTemplate.VolumeMounts = append(n.Spec.PodTemplate.VolumeMounts, corev1.VolumeMount{ + Name: sessionTicketsVolumeName, + MountPath: sessionTicketsVolumeMountPath, + ReadOnly: true, + }) + } + if !plan.Spec.Config.CacheSnapshotEnabled { return n } @@ -947,6 +1318,15 @@ func newCronJob(instance *v1alpha1.RpaasInstance, plan *v1alpha1.RpaasPlan) *bat } } +func minutesIntervalToSchedule(minutes uint32) string { + oneMinute := uint32(1) + if minutes <= oneMinute { + minutes = oneMinute + } + + return fmt.Sprintf("*/%d * * * *", minutes) +} + func interpolateCacheSnapshotPodCmdTemplate(podCmd string, plan *v1alpha1.RpaasPlan) string { replacer := strings.NewReplacer( "${CACHE_SNAPSHOT_MOUNTPOINT}", cacheSnapshotMountPoint, diff --git a/pkg/controller/rpaasinstance/rpaasinstance_controller_test.go b/pkg/controller/rpaasinstance/rpaasinstance_controller_test.go index 8c1bf6107..1a273c083 100644 --- a/pkg/controller/rpaasinstance/rpaasinstance_controller_test.go +++ b/pkg/controller/rpaasinstance/rpaasinstance_controller_test.go @@ -6,6 +6,7 @@ package rpaasinstance import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -972,6 +973,8 @@ func newRpaasFlavor() *v1alpha1.RpaasFlavor { func newScheme() *runtime.Scheme { scheme := runtime.NewScheme() + corev1.SchemeBuilder.AddToScheme(scheme) + batchv1beta1.SchemeBuilder.AddToScheme(scheme) autoscalingv2beta2.SchemeBuilder.AddToScheme(scheme) v1alpha1.SchemeBuilder.AddToScheme(scheme) nginxv1alpha1.SchemeBuilder.AddToScheme(scheme) @@ -1580,3 +1583,292 @@ func resourceMustParsePtr(fmt string) *resource.Quantity { qty := resource.MustParse(fmt) return &qty } + +func TestMinutesIntervalToSchedule(t *testing.T) { + tests := []struct { + minutes uint32 + want string + }{ + { + want: "*/1 * * * *", + }, + { + minutes: 60, // an hour + want: "*/60 * * * *", + }, + { + minutes: 12 * 60, // a half day + want: "*/720 * * * *", + }, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%d min == %q", tt.minutes, tt.want), func(t *testing.T) { + have := minutesIntervalToSchedule(tt.minutes) + assert.Equal(t, tt.want, have) + }) + } +} + +func TestReconcileRpaasInstance_reconcileTLSSessionResumption(t *testing.T) { + tests := []struct { + name string + instance *v1alpha1.RpaasInstance + objects []runtime.Object + assert func(t *testing.T, err error, gotSecret *corev1.Secret, gotCronJob *batchv1beta1.CronJob) + }{ + { + name: "when no TLS session resumption is enabled", + instance: &v1alpha1.RpaasInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-instance", + Namespace: "default", + }, + }, + }, + { + name: "Session Tickets: default container image + default key length + default rotation interval", + instance: &v1alpha1.RpaasInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-instance", + Namespace: "default", + }, + Spec: v1alpha1.RpaasInstanceSpec{ + TLSSessionResumption: &v1alpha1.TLSSessionResumption{ + SessionTicket: &v1alpha1.TLSSessionTicket{}, + }, + }, + }, + assert: func(t *testing.T, err error, gotSecret *corev1.Secret, gotCronJob *batchv1beta1.CronJob) { + require.NoError(t, err) + require.NotNil(t, gotSecret) + + expectedKeyLength := 48 + + currentTicket, ok := gotSecret.Data["ticket.0.key"] + require.True(t, ok) + require.NotEmpty(t, currentTicket) + require.Len(t, currentTicket, expectedKeyLength) + + require.NotNil(t, gotCronJob) + assert.Equal(t, "*/60 * * * *", gotCronJob.Spec.Schedule) + assert.Equal(t, corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + rotateTLSSessionTicketsScriptFilename: rotateTLSSessionTicketsScript, + }, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: rotateTLSSessionTicketsServiceAccountName, + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "session-ticket-rotator", + Image: defaultRotateTLSSessionTicketsImage, + Command: []string{"/bin/bash"}, + Args: []string{rotateTLSSessionTicketsScriptPath}, + Env: []corev1.EnvVar{ + { + Name: "SECRET_NAME", + Value: gotSecret.Name, + }, + { + Name: "SECRET_NAMESPACE", + Value: gotSecret.Namespace, + }, + { + Name: "SESSION_TICKET_KEY_LENGTH", + Value: fmt.Sprint(expectedKeyLength), + }, + { + Name: "SESSION_TICKET_KEYS", + Value: "1", + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: rotateTLSSessionTicketsVolumeName, + MountPath: rotateTLSSessionTicketsScriptPath, + SubPath: rotateTLSSessionTicketsScriptFilename, + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: rotateTLSSessionTicketsVolumeName, + VolumeSource: corev1.VolumeSource{ + DownwardAPI: &corev1.DownwardAPIVolumeSource{ + Items: []corev1.DownwardAPIVolumeFile{ + { + Path: rotateTLSSessionTicketsScriptFilename, + FieldRef: &corev1.ObjectFieldSelector{ + FieldPath: fmt.Sprintf("metadata.annotations['%s']", rotateTLSSessionTicketsScriptFilename), + }, + }, + }, + }, + }, + }, + }, + }, + }, gotCronJob.Spec.JobTemplate.Spec.Template) + }, + }, + { + name: "Session Ticket: update key length and rotatation interval", + objects: []runtime.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-instance-session-tickets", + Namespace: "default", + }, + }, + &batchv1beta1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-instance-session-tickets", + Namespace: "default", + }, + }, + }, + instance: &v1alpha1.RpaasInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-instance", + Namespace: "default", + }, + Spec: v1alpha1.RpaasInstanceSpec{ + TLSSessionResumption: &v1alpha1.TLSSessionResumption{ + SessionTicket: &v1alpha1.TLSSessionTicket{ + KeepLastKeys: uint32(3), + KeyRotationInterval: uint32(60 * 24), // a day + KeyLength: v1alpha1.SessionTicketKeyLength80, + Image: "my.custom.image:tag", + }, + }, + }, + }, + assert: func(t *testing.T, err error, gotSecret *corev1.Secret, gotCronJob *batchv1beta1.CronJob) { + require.NoError(t, err) + require.NotNil(t, gotSecret) + require.NotNil(t, gotCronJob) + + expectedKeyLength := 80 + assert.Len(t, gotSecret.Data, 4) + for i := 0; i < 4; i++ { + assert.Len(t, gotSecret.Data[fmt.Sprintf("ticket.%d.key", i)], expectedKeyLength) + } + + assert.Equal(t, "*/1440 * * * *", gotCronJob.Spec.Schedule) + assert.Equal(t, "my.custom.image:tag", gotCronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) + assert.Contains(t, gotCronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{Name: "SESSION_TICKET_KEY_LENGTH", Value: "80"}) + assert.Contains(t, gotCronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{Name: "SESSION_TICKET_KEYS", Value: "4"}) + }, + }, + { + name: "when session ticket is disabled, should remove Secret and CronJob objects", + objects: []runtime.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-instance-session-tickets", + Namespace: "default", + }, + }, + &batchv1beta1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-instance-session-tickets", + Namespace: "default", + }, + }, + }, + instance: &v1alpha1.RpaasInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-instance", + Namespace: "default", + }, + Spec: v1alpha1.RpaasInstanceSpec{ + TLSSessionResumption: &v1alpha1.TLSSessionResumption{}, + }, + }, + assert: func(t *testing.T, err error, gotSecret *corev1.Secret, gotCronJob *batchv1beta1.CronJob) { + require.NoError(t, err) + assert.Empty(t, gotSecret.Name) + assert.Empty(t, gotCronJob.Name) + }, + }, + { + name: "when decreasing the number of keys", + instance: &v1alpha1.RpaasInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-instance", + Namespace: "default", + }, + Spec: v1alpha1.RpaasInstanceSpec{ + TLSSessionResumption: &v1alpha1.TLSSessionResumption{ + SessionTicket: &v1alpha1.TLSSessionTicket{ + KeepLastKeys: uint32(1), + }, + }, + }, + }, + objects: []runtime.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-instance-session-tickets", + Namespace: "default", + }, + Data: map[string][]byte{ + "ticket.0.key": {'h', 'e', 'l', 'l', 'o'}, + "ticket.1.key": {'w', 'o', 'r', 'd', '!'}, + "ticket.2.key": {'f', 'o', 'o'}, + "ticket.3.key": {'b', 'a', 'r'}, + }, + }, + }, + assert: func(t *testing.T, err error, gotSecret *corev1.Secret, gotCronJob *batchv1beta1.CronJob) { + require.NoError(t, err) + + expectedKeys := 2 + assert.Len(t, gotSecret.Data, expectedKeys) + assert.Equal(t, gotSecret.Data["ticket.0.key"], []byte{'h', 'e', 'l', 'l', 'o'}) + assert.Equal(t, gotSecret.Data["ticket.1.key"], []byte{'w', 'o', 'r', 'd', '!'}) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resources := []runtime.Object{tt.instance} + if tt.objects != nil { + resources = append(resources, tt.objects...) + } + + scheme := newScheme() + r := &ReconcileRpaasInstance{ + client: fake.NewFakeClientWithScheme(scheme, resources...), + scheme: scheme, + } + + err := r.reconcileTLSSessionResumption(context.TODO(), tt.instance) + if tt.assert == nil { + require.NoError(t, err) + return + } + + var secret corev1.Secret + secretName := types.NamespacedName{ + Name: tt.instance.Name + sessionTicketsSecretSuffix, + Namespace: tt.instance.Namespace, + } + r.client.Get(context.TODO(), secretName, &secret) + + var cronJob batchv1beta1.CronJob + cronJobName := types.NamespacedName{ + Name: tt.instance.Name + sessionTicketsCronJobSuffix, + Namespace: tt.instance.Namespace, + } + r.client.Get(context.TODO(), cronJobName, &cronJob) + + tt.assert(t, err, &secret, &cronJob) + }) + } +} diff --git a/tsuru-rpaasv2-0.9.0-1.rockspec b/tsuru-rpaasv2-0.9.0-1.rockspec new file mode 100644 index 000000000..6bb5c176f --- /dev/null +++ b/tsuru-rpaasv2-0.9.0-1.rockspec @@ -0,0 +1,23 @@ +package = "tsuru-rpaasv2" +version = "0.9.0-1" +source = { + url = "git://github.com/tsuru/rpaas-operator.git", + tag = "v0.9.0" +} +description = { + summary = "Lua helpers to extend Nginx (OpenResty or just ngx_lua) features on RPaaS v2.", + homepage = "https://github.com/tsuru/rpaas-operator", + license = "3-clause BSD", + maintainer = "Tsuru " +} +dependencies = { + "lua >= 5.1", + "inotify ~> 0.5", +} +build = { + type = "builtin", + modules = { + ["tsuru.rpaasv2.tls.session_ticket"] = "lualib/tsuru/rpaasv2/tls/session_ticket.lua", + ["tsuru.rpaasv2.tls.session_ticket_reloader"] = "lualib/tsuru/rpaasv2/tls/session_ticket_reloader.lua" + } +}