From 7881f541ad9d50e62ce453ab3f207aea15d798c0 Mon Sep 17 00:00:00 2001 From: Doug MacEachern Date: Sun, 19 Apr 2020 21:23:57 -0700 Subject: [PATCH] Add REST session keep alive support Refactor the SOAP based session.KeepAlive into its own package that can be used for both SOAP and REST sessions. - Expose the Start and Stop methods for use with cache.Client (e.g. govc) - govc: enable KeepAlive in library.{deploy,import} commands - vcsim: add idle session expiration support Fixes #1832 Fixes #1839 --- govc/flags/client.go | 12 ++ govc/library/deploy.go | 7 + govc/library/import.go | 1 + session/keep_alive.go | 111 ++---------- session/keep_alive_test.go | 269 +++--------------------------- session/keepalive/example_test.go | 149 +++++++++++++++++ session/keepalive/handler.go | 204 ++++++++++++++++++++++ session/keepalive/handler_test.go | 173 +++++++++++++++++++ simulator/session_manager.go | 86 +++++++++- vapi/simulator/simulator.go | 15 ++ 10 files changed, 667 insertions(+), 360 deletions(-) create mode 100644 session/keepalive/example_test.go create mode 100644 session/keepalive/handler.go create mode 100644 session/keepalive/handler_test.go diff --git a/govc/flags/client.go b/govc/flags/client.go index 6eefdf841..fe8b451c4 100644 --- a/govc/flags/client.go +++ b/govc/flags/client.go @@ -32,6 +32,7 @@ import ( "github.com/vmware/govmomi/session" "github.com/vmware/govmomi/session/cache" + "github.com/vmware/govmomi/session/keepalive" "github.com/vmware/govmomi/vapi/rest" "github.com/vmware/govmomi/vim25" "github.com/vmware/govmomi/vim25/soap" @@ -392,6 +393,17 @@ func (flag *ClientFlag) RestClient() (*rest.Client, error) { return flag.restClient, nil } +func (flag *ClientFlag) KeepAlive(client cache.Client) { + switch c := client.(type) { + case *vim25.Client: + keepalive.NewHandlerSOAP(c, 0, nil).Start() + case *rest.Client: + keepalive.NewHandlerREST(c, 0, nil).Start() + default: + panic(fmt.Sprintf("unsupported client type=%T", client)) + } +} + func (flag *ClientFlag) Logout(ctx context.Context) error { if flag.client != nil { _ = flag.Session.Logout(ctx, flag.client) diff --git a/govc/library/deploy.go b/govc/library/deploy.go index af320adee..16d56e52a 100644 --- a/govc/library/deploy.go +++ b/govc/library/deploy.go @@ -99,10 +99,17 @@ func (cmd *deploy) Run(ctx context.Context, f *flag.FlagSet) error { name = *cmd.Options.Name } + vc, err := cmd.DatastoreFlag.Client() + if err != nil { + return err + } + cmd.KeepAlive(vc) + c, err := cmd.DatastoreFlag.RestClient() if err != nil { return err } + cmd.KeepAlive(c) m := vcenter.NewManager(c) diff --git a/govc/library/import.go b/govc/library/import.go index f3c81f140..4ad638130 100644 --- a/govc/library/import.go +++ b/govc/library/import.go @@ -144,6 +144,7 @@ func (cmd *item) Run(ctx context.Context, f *flag.FlagSet) error { if err != nil { return err } + cmd.KeepAlive(c) m := library.NewManager(c) res, err := flags.ContentLibraryResult(ctx, c, "", f.Arg(0)) diff --git a/session/keep_alive.go b/session/keep_alive.go index 04cc3f2b1..7c1bebf91 100644 --- a/session/keep_alive.go +++ b/session/keep_alive.go @@ -1,5 +1,5 @@ /* -Copyright (c) 2015,2019 VMware, Inc. All Rights Reserved. +Copyright (c) 2015-2020 VMware, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,115 +17,24 @@ limitations under the License. package session import ( - "context" - "sync" "time" - "github.com/vmware/govmomi/vim25/methods" + "github.com/vmware/govmomi/session/keepalive" "github.com/vmware/govmomi/vim25/soap" ) -type keepAlive struct { - sync.Mutex - - roundTripper soap.RoundTripper - idleTime time.Duration - notifyRequest chan struct{} - notifyStop chan struct{} - notifyWaitGroup sync.WaitGroup - - // keepAlive executes a request in the background with the purpose of - // keeping the session active. The response for this request is discarded. - keepAlive func(soap.RoundTripper) error -} - -func defaultKeepAlive(roundTripper soap.RoundTripper) error { - _, err := methods.GetCurrentTime(context.Background(), roundTripper) - return err -} - -// KeepAlive wraps the specified soap.RoundTripper and executes a meaningless -// API request in the background after the RoundTripper has been idle for the -// specified amount of idle time. The keep alive process only starts once a -// user logs in and runs until the user logs out again. +// KeepAlive is a backward compatible wrapper around KeepAliveHandler. func KeepAlive(roundTripper soap.RoundTripper, idleTime time.Duration) soap.RoundTripper { - return KeepAliveHandler(roundTripper, idleTime, defaultKeepAlive) + return KeepAliveHandler(roundTripper, idleTime, nil) } -// KeepAliveHandler works as KeepAlive() does, but the handler param can decide how to handle errors. -// For example, if connectivity to ESX/VC is down long enough for a session to expire, a handler can choose to -// Login() on a types.NotAuthenticated error. If handler returns non-nil, the keep alive go routine will be stopped. +// KeepAliveHandler is a backward compatible wrapper around keepalive.NewHandlerSOAP. func KeepAliveHandler(roundTripper soap.RoundTripper, idleTime time.Duration, handler func(soap.RoundTripper) error) soap.RoundTripper { - k := &keepAlive{ - roundTripper: roundTripper, - idleTime: idleTime, - notifyRequest: make(chan struct{}), - } - - k.keepAlive = handler - - return k -} - -func (k *keepAlive) start() { - k.Lock() - defer k.Unlock() - - if k.notifyStop != nil { - return - } - - // This channel must be closed to terminate idle timer. - k.notifyStop = make(chan struct{}) - k.notifyWaitGroup.Add(1) - - go func() { - for t := time.NewTimer(k.idleTime); ; { - select { - case <-k.notifyStop: - k.notifyWaitGroup.Done() - return - case <-k.notifyRequest: - t.Reset(k.idleTime) - case <-t.C: - if err := k.keepAlive(k.roundTripper); err != nil { - k.notifyWaitGroup.Done() - k.stop() - return - } - t = time.NewTimer(k.idleTime) - } + var f func() error + if handler != nil { + f = func() error { + return handler(roundTripper) } - }() -} - -func (k *keepAlive) stop() { - k.Lock() - defer k.Unlock() - - if k.notifyStop != nil { - close(k.notifyStop) - k.notifyWaitGroup.Wait() - k.notifyStop = nil - } -} - -func (k *keepAlive) RoundTrip(ctx context.Context, req, res soap.HasFault) error { - // Stop ticker on logout. - switch req.(type) { - case *methods.LogoutBody: - k.stop() - } - - err := k.roundTripper.RoundTrip(ctx, req, res) - if err != nil { - return err - } - // Start ticker on login. - switch req.(type) { - case *methods.LoginBody, *methods.LoginExtensionByCertificateBody, *methods.LoginByTokenBody: - k.start() } - - return nil + return keepalive.NewHandlerSOAP(roundTripper, idleTime, f) } diff --git a/session/keep_alive_test.go b/session/keep_alive_test.go index a24a84442..26bf7bc34 100644 --- a/session/keep_alive_test.go +++ b/session/keep_alive_test.go @@ -18,277 +18,46 @@ package session_test import ( "context" - "fmt" - "net/url" - "os" - "runtime" - "sync/atomic" + "errors" + "sync" "testing" "time" "github.com/vmware/govmomi/session" "github.com/vmware/govmomi/simulator" - "github.com/vmware/govmomi/test" "github.com/vmware/govmomi/vim25" - "github.com/vmware/govmomi/vim25/methods" "github.com/vmware/govmomi/vim25/soap" - "github.com/vmware/govmomi/vim25/types" ) -type testKeepAlive struct { - val int32 -} - -func (t *testKeepAlive) Func(soap.RoundTripper) error { - atomic.AddInt32(&t.val, 1) - return nil -} - -func (t *testKeepAlive) Value() int { - n := atomic.LoadInt32(&t.val) - return int(n) -} - -type manager struct { - *session.Manager - rt soap.RoundTripper -} - -func newManager(u *url.URL, idle time.Duration, handler func(soap.RoundTripper) error) manager { - sc := soap.NewClient(u, true) - vc, err := vim25.NewClient(context.Background(), sc) - if err != nil { - panic(err) - } - - if idle != 0 { - if handler == nil { - vc.RoundTripper = session.KeepAlive(vc.RoundTripper, idle) - } else { - vc.RoundTripper = session.KeepAliveHandler(vc.RoundTripper, idle, handler) - } - } - - return manager{session.NewManager(vc), vc.RoundTripper} -} - func TestKeepAlive(t *testing.T) { simulator.Test(func(ctx context.Context, c *vim25.Client) { - var i testKeepAlive - var j int - - m := newManager(c.URL(), time.Millisecond, i.Func) - - // Expect keep alive to not have triggered yet - if i.Value() != 0 { - t.Errorf("Expected i == 0, got i: %d", i) - } - - // Logging in starts keep alive - err := m.Login(ctx, simulator.DefaultLogin) - if err != nil { - t.Error(err) - } - - time.Sleep(2 * time.Millisecond) - - // Expect keep alive to triggered at least once - if i.Value() == 0 { - t.Errorf("Expected i != 0, got i: %d", i) - } - - j = i.Value() - time.Sleep(2 * time.Millisecond) - - // Expect keep alive to triggered at least once more - if i.Value() <= j { - t.Errorf("Expected i > j, got i: %d, j: %d", i, j) - } - - // Logging out stops keep alive - err = m.Logout(context.Background()) - if err != nil { - t.Error(err) - } - - j = i.Value() - time.Sleep(2 * time.Millisecond) - - // Expect keep alive to have stopped - if i.Value() != j { - t.Errorf("Expected i == j, got i: %d, j: %d", i, j) - } - }) -} - -func testSessionOK(t *testing.T, m manager, ok bool) { - s, err := m.UserSession(context.Background()) - if err != nil { - t.Fatal(err) - } - - _, file, line, _ := runtime.Caller(1) - prefix := fmt.Sprintf("%s:%d", file, line) - - if ok && s == nil { - t.Fatalf("%s: Expected session to be OK, but is invalid", prefix) - } - - if !ok && s != nil { - t.Fatalf("%s: Expected session to be invalid, but is OK", prefix) - } -} - -// Run with: -// -// env GOVMOMI_KEEPALIVE_TEST=1 go test -timeout=60m -run TestRealKeepAlive -// -func TestRealKeepAlive(t *testing.T) { - if os.Getenv("GOVMOMI_KEEPALIVE_TEST") != "1" { - t.SkipNow() - } - u := test.URL() - if u == nil { - t.SkipNow() - } - var m1, m2 manager - m1 = newManager(u, 0, nil) - // Enable keepalive on m2 - m2 = newManager(u, 10*time.Minute, nil) - - // Expect both sessions to be invalid - testSessionOK(t, m1, false) - testSessionOK(t, m2, false) - - // Logging in starts keep alive - if err := m1.Login(context.Background(), u.User); err != nil { - t.Error(err) - } - if err := m2.Login(context.Background(), u.User); err != nil { - t.Error(err) - } - - // Expect both sessions to be valid - testSessionOK(t, m1, true) - testSessionOK(t, m2, true) - - // Wait for m1 to time out - delay := 31 * time.Minute - fmt.Printf("%s: Waiting %d minutes for session to time out...\n", time.Now(), int(delay.Minutes())) - time.Sleep(delay) - - // Expect m1's session to be invalid, m2's session to be valid - testSessionOK(t, m1, false) - testSessionOK(t, m2, true) -} - -func isNotAuthenticated(err error) bool { - if soap.IsSoapFault(err) { - switch soap.ToSoapFault(err).VimFault().(type) { - case types.NotAuthenticated: - return true - } - } - return false -} - -func isInvalidLogin(err error) bool { - if soap.IsSoapFault(err) { - switch soap.ToSoapFault(err).VimFault().(type) { - case types.InvalidLogin: - return true - } - } - return false -} - -func TestKeepAliveHandler(t *testing.T) { - simulator.Test(func(ctx context.Context, c *vim25.Client) { - reauth := make(chan bool) - login := simulator.DefaultLogin - var m1, m2 manager - m1 = newManager(c.URL(), 0, nil) - // Keep alive handler that will re-login. - // Real-world case: connectivity to ESX/VC is down long enough for the session to expire - // Test-world case: we call TerminateSession below - m2 = newManager(c.URL(), time.Second, func(roundTripper soap.RoundTripper) error { - _, err := methods.GetCurrentTime(ctx, roundTripper) - if err != nil { - if isNotAuthenticated(err) { - err = m2.Login(ctx, login) - if err != nil { - if isInvalidLogin(err) { - reauth <- false - t.Log("failed to re-authenticate, quitting keep alive handler") - return err - } - } else { - reauth <- true - } - } - } - - return nil - }) - - // Logging in starts keep alive - if err := m1.Login(ctx, simulator.DefaultLogin); err != nil { - t.Error(err) - } - defer m1.Logout(ctx) - - if err := m2.Login(ctx, simulator.DefaultLogin); err != nil { - t.Error(err) - } - defer m2.Logout(ctx) + m := session.NewManager(c) - // Terminate session for m2. Note that self terminate fails, so we need 2 sessions for this test. - s, err := m2.UserSession(ctx) + err := m.Logout(ctx) if err != nil { t.Fatal(err) } - err = m1.TerminateSession(ctx, []string{s.Key}) - if err != nil { - t.Fatal(err) - } - - _, err = methods.GetCurrentTime(ctx, m2.rt) - if err == nil { - t.Error("expected to fail") - } - - // Wait for keepalive to re-authenticate - <-reauth - - _, err = methods.GetCurrentTime(ctx, m2.rt) - if err != nil { - t.Fatal(err) - } - - // Clear credentials to test re-authentication failure - login = nil - - s, err = m2.UserSession(ctx) - if err != nil { - t.Fatal(err) - } + var mu sync.Mutex + n := 0 + c.RoundTripper = vim25.Retry(c.Client, vim25.TemporaryNetworkError(3)) + c.RoundTripper = session.KeepAliveHandler(c.RoundTripper, time.Millisecond, func(soap.RoundTripper) error { + mu.Lock() + n++ + mu.Unlock() + return errors.New("stop") // stops the keep alive routine + }) - err = m1.TerminateSession(ctx, []string{s.Key}) + err = m.Login(ctx, simulator.DefaultLogin) // starts the keep alive routine if err != nil { t.Fatal(err) } - // Wait for keepalive re-authenticate attempt - result := <-reauth - - _, err = methods.GetCurrentTime(ctx, m2.rt) - if err == nil { - t.Error("expected to fail") - } - - if result { - t.Errorf("expected reauth to fail") + time.Sleep(time.Millisecond * 10) + mu.Lock() + if n != 1 { + t.Errorf("handler called %d times", n) } + mu.Unlock() }) } diff --git a/session/keepalive/example_test.go b/session/keepalive/example_test.go new file mode 100644 index 000000000..cf6b865a5 --- /dev/null +++ b/session/keepalive/example_test.go @@ -0,0 +1,149 @@ +/* +Copyright (c) 2020 VMware, Inc. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package keepalive_test + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/vmware/govmomi/session" + "github.com/vmware/govmomi/session/keepalive" + "github.com/vmware/govmomi/simulator" + "github.com/vmware/govmomi/vapi/rest" + "github.com/vmware/govmomi/vim25" +) + +var ( + sessionCheckPause = time.Second / 2 + sessionIdleTimeout = sessionCheckPause / 2 + keepAliveIdle = sessionIdleTimeout / 2 +) + +func init() { + simulator.SessionIdleTimeout = sessionIdleTimeout +} + +func ExampleHandlerSOAP() { + simulator.Run(func(ctx context.Context, c *vim25.Client) error { + // No need for initial Login() here as simulator.Run already has + m := session.NewManager(c) + + // check twice if session is valid, sleeping > SessionIdleTimeout in between + check := func() { + for i := 0; i < 2; i++ { + s, err := m.UserSession(ctx) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("session valid=%t\n", s != nil) + if i == 0 { + time.Sleep(sessionCheckPause) + } + } + } + + // session will expire here + check() + + // this starts the keep alive handler when Login is called, and stops the handler when Logout is called + c.RoundTripper = keepalive.NewHandlerSOAP(c.RoundTripper, keepAliveIdle, nil) + + err := m.Login(ctx, simulator.DefaultLogin) + if err != nil { + return err + } + + // session will not expire here, with the keep alive handler in place. + check() + + err = m.Logout(ctx) + if err != nil { + return err + } + + // Logout() also stops the keep alive handler, session is no longer valid. + check() + + return nil + }) + // Output: + // session valid=true + // session valid=false + // session valid=true + // session valid=true + // session valid=false + // session valid=false +} + +func ExampleHandlerREST() { + simulator.Run(func(ctx context.Context, vc *vim25.Client) error { + c := rest.NewClient(vc) + err := c.Login(ctx, simulator.DefaultLogin) + if err != nil { + return err + } + + // check twice if session is valid, sleeping > SessionIdleTimeout in between. + check := func() { + for i := 0; i < 2; i++ { + s, err := c.Session(ctx) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("session valid=%t\n", s != nil) + if i == 0 { + time.Sleep(sessionCheckPause) + } + } + } + + // session will expire here + check() + + // this starts the keep alive handler when Login is called, and stops the handler when Logout is called + c.Transport = keepalive.NewHandlerREST(c, keepAliveIdle, nil) + + err = c.Login(ctx, simulator.DefaultLogin) + if err != nil { + return err + } + + // session will not expire here, with the keep alive handler in place. + check() + + err = c.Logout(ctx) + if err != nil { + return err + } + + // Logout() also stops the keep alive handler, session is no longer valid. + check() + + return nil + }) + // Output: + // session valid=true + // session valid=false + // session valid=true + // session valid=true + // session valid=false + // session valid=false +} diff --git a/session/keepalive/handler.go b/session/keepalive/handler.go new file mode 100644 index 000000000..3ebf046a5 --- /dev/null +++ b/session/keepalive/handler.go @@ -0,0 +1,204 @@ +/* +Copyright (c) 2020 VMware, Inc. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package keepalive + +import ( + "context" + "errors" + "net/http" + "sync" + "time" + + "github.com/vmware/govmomi/vapi/rest" + "github.com/vmware/govmomi/vim25/methods" + "github.com/vmware/govmomi/vim25/soap" +) + +// handler contains the generic keep alive settings and logic +type handler struct { + mu sync.Mutex + notifyStop chan struct{} + notifyWaitGroup sync.WaitGroup + + idle time.Duration + send func() error +} + +// NewHandlerSOAP returns a soap.RoundTripper for use with a vim25.Client +// The idle time specifies the interval in between send() requests. Defaults to 10 minutes. +// The send func is used to keep a session alive. Defaults to calling vim25 GetCurrentTime(). +// The keep alive goroutine starts when a Login method is called and runs until Logout is called or send returns an error. +func NewHandlerSOAP(c soap.RoundTripper, idle time.Duration, send func() error) *HandlerSOAP { + h := &handler{ + idle: idle, + send: send, + } + + if send == nil { + h.send = func() error { + return h.keepAliveSOAP(c) + } + } + + return &HandlerSOAP{h, c} +} + +// NewHandlerREST returns an http.RoundTripper for use with a rest.Client +// The idle time specifies the interval in between send() requests. Defaults to 10 minutes. +// The send func is used to keep a session alive. Defaults to calling the rest.Client.Session() method +// The keep alive goroutine starts when a Login method is called and runs until Logout is called or send returns an error. +func NewHandlerREST(c *rest.Client, idle time.Duration, send func() error) *HandlerREST { + h := &handler{ + idle: idle, + send: send, + } + + if send == nil { + h.send = func() error { + return h.keepAliveREST(c) + } + } + + return &HandlerREST{h, c.Transport} +} + +func (h *handler) keepAliveSOAP(rt soap.RoundTripper) error { + ctx := context.Background() + _, err := methods.GetCurrentTime(ctx, rt) + return err +} + +func (h *handler) keepAliveREST(c *rest.Client) error { + ctx := context.Background() + + s, err := c.Session(ctx) + if err != nil { + return err + } + if s != nil { + return nil + } + return errors.New(http.StatusText(http.StatusUnauthorized)) +} + +// Start explicitly starts the keep alive go routine. +// For use with session cache.Client, as cached sessions may not involve Login/Logout via RoundTripper. +func (h *handler) Start() { + h.mu.Lock() + defer h.mu.Unlock() + + if h.notifyStop != nil { + return + } + + if h.idle == 0 { + h.idle = time.Minute * 10 + } + + // This channel must be closed to terminate idle timer. + h.notifyStop = make(chan struct{}) + h.notifyWaitGroup.Add(1) + + go func() { + for t := time.NewTimer(h.idle); ; { + select { + case <-h.notifyStop: + h.notifyWaitGroup.Done() + t.Stop() + return + case <-t.C: + if err := h.send(); err != nil { + h.notifyWaitGroup.Done() + h.Stop() + return + } + t.Reset(h.idle) + } + } + }() +} + +// Stop explicitly stops the keep alive go routine. +// For use with session cache.Client, as cached sessions may not involve Login/Logout via RoundTripper. +func (h *handler) Stop() { + h.mu.Lock() + defer h.mu.Unlock() + + if h.notifyStop != nil { + close(h.notifyStop) + h.notifyWaitGroup.Wait() + h.notifyStop = nil + } +} + +// HandlerSOAP is a keep alive implementation for use with vim25.Client +type HandlerSOAP struct { + *handler + + roundTripper soap.RoundTripper +} + +// RoundTrip implements soap.RoundTripper +func (h *HandlerSOAP) RoundTrip(ctx context.Context, req, res soap.HasFault) error { + // Stop ticker on logout. + switch req.(type) { + case *methods.LogoutBody: + h.Stop() + } + + err := h.roundTripper.RoundTrip(ctx, req, res) + if err != nil { + return err + } + + // Start ticker on login. + switch req.(type) { + case *methods.LoginBody, *methods.LoginExtensionByCertificateBody, *methods.LoginByTokenBody: + h.Start() + } + + return nil +} + +// HandlerREST is a keep alive implementation for use with rest.Client +type HandlerREST struct { + *handler + + roundTripper http.RoundTripper +} + +// RoundTrip implements http.RoundTripper +func (h *HandlerREST) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/rest/com/vmware/cis/session" { + return h.roundTripper.RoundTrip(req) + } + + if req.Method == http.MethodDelete { // Logout + h.Stop() + } + + res, err := h.roundTripper.RoundTrip(req) + if err != nil { + return res, err + } + + if req.Method == http.MethodPost { // Login + h.Start() + } + + return res, err +} diff --git a/session/keepalive/handler_test.go b/session/keepalive/handler_test.go new file mode 100644 index 000000000..61123674d --- /dev/null +++ b/session/keepalive/handler_test.go @@ -0,0 +1,173 @@ +/* +Copyright (c) 2020 VMware, Inc. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package keepalive_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/vmware/govmomi/session" + "github.com/vmware/govmomi/session/keepalive" + "github.com/vmware/govmomi/simulator" + "github.com/vmware/govmomi/vapi/rest" + _ "github.com/vmware/govmomi/vapi/simulator" + "github.com/vmware/govmomi/vim25" + "github.com/vmware/govmomi/vim25/soap" +) + +type count struct { + sync.Mutex + val int32 +} + +func (t *count) Send() error { + t.Lock() + defer t.Unlock() + t.val++ + return nil +} + +func (t *count) Value() int { + t.Lock() + defer t.Unlock() + return int(t.val) +} + +func TestHandlerSOAP(t *testing.T) { + simulator.Test(func(ctx context.Context, c *vim25.Client) { + var i count + + sc := soap.NewClient(c.URL(), true) + vc, err := vim25.NewClient(ctx, sc) + if err != nil { + t.Fatal(err) + } + + vc.RoundTripper = keepalive.NewHandlerSOAP(sc, time.Millisecond, i.Send) + + m := session.NewManager(vc) + + // Expect keep alive to not have triggered yet + v := i.Value() + if v != 0 { + t.Errorf("Expected i == 0, got i: %d", v) + } + + // Logging in starts keep alive + err = m.Login(ctx, simulator.DefaultLogin) + if err != nil { + t.Error(err) + } + + time.Sleep(10 * time.Millisecond) + + // Expect keep alive to triggered at least once + v = i.Value() + if v == 0 { + t.Errorf("Expected i != 0, got i: %d", v) + } + + j := i.Value() + time.Sleep(10 * time.Millisecond) + + // Expect keep alive to triggered at least once more + v = i.Value() + if v <= j { + t.Errorf("Expected i > j, got i: %d, j: %d", v, j) + } + + // Logging out stops keep alive + err = m.Logout(ctx) + if err != nil { + t.Error(err) + } + + j = i.Value() + time.Sleep(10 * time.Millisecond) + + // Expect keep alive to have stopped + v = i.Value() + if v != j { + t.Errorf("Expected i == j, got i: %d, j: %d", v, j) + } + }) +} + +func TestHandlerREST(t *testing.T) { + simulator.Test(func(ctx context.Context, c *vim25.Client) { + var i count + + sc := soap.NewClient(c.URL(), true) + vc, err := vim25.NewClient(ctx, sc) + if err != nil { + t.Fatal(err) + } + + rc := rest.NewClient(vc) + rc.Transport = keepalive.NewHandlerREST(rc, time.Millisecond, i.Send) + err = rc.Login(ctx, simulator.DefaultLogin) + if err != nil { + t.Fatal(err) + } + + // Expect keep alive to not have triggered yet + v := i.Value() + if v != 0 { + t.Errorf("Expected i == 0, got i: %d", v) + } + + // Logging in starts keep alive + err = rc.Login(ctx, simulator.DefaultLogin) + if err != nil { + t.Error(err) + } + + time.Sleep(10 * time.Millisecond) + + // Expect keep alive to triggered at least once + v = i.Value() + if v == 0 { + t.Errorf("Expected i != 0, got i: %d", v) + } + + j := i.Value() + time.Sleep(10 * time.Millisecond) + + // Expect keep alive to triggered at least once more + v = i.Value() + if v <= j { + t.Errorf("Expected i > j, got i: %d, j: %d", v, j) + } + + // Logging out stops keep alive + err = rc.Logout(ctx) + if err != nil { + t.Error(err) + } + + j = i.Value() + time.Sleep(10 * time.Millisecond) + + // Expect keep alive to have stopped + v = i.Value() + if v != j { + t.Errorf("Expected i == j, got i: %d, j: %d", v, j) + } + }) +} diff --git a/simulator/session_manager.go b/simulator/session_manager.go index 625f46aba..3689226ec 100644 --- a/simulator/session_manager.go +++ b/simulator/session_manager.go @@ -22,6 +22,7 @@ import ( "net/http" "reflect" "strings" + "sync" "time" "github.com/google/uuid" @@ -34,6 +35,7 @@ import ( type SessionManager struct { mo.SessionManager + nopLocker ServiceHostName string TLSCert func() string @@ -45,6 +47,13 @@ func (m *SessionManager) init(*Registry) { m.sessions = make(map[string]Session) } +var ( + // SessionIdleTimeout duration used to expire idle sessions + SessionIdleTimeout time.Duration + + sessionMutex sync.Mutex +) + func createSession(ctx *Context, name string, locale string) types.UserSession { now := time.Now().UTC() @@ -71,6 +80,25 @@ func createSession(ctx *Context, name string, locale string) types.UserSession { return ctx.Session.UserSession } +func (m *SessionManager) getSession(id string) (Session, bool) { + sessionMutex.Lock() + defer sessionMutex.Unlock() + s, ok := m.sessions[id] + return s, ok +} + +func (m *SessionManager) delSession(id string) { + sessionMutex.Lock() + defer sessionMutex.Unlock() + delete(m.sessions, id) +} + +func (m *SessionManager) putSession(s Session) { + sessionMutex.Lock() + defer sessionMutex.Unlock() + m.sessions[s.Key] = s +} + func (s *SessionManager) validLogin(ctx *Context, req *types.Login) bool { if ctx.Session != nil { return false @@ -145,7 +173,7 @@ func (s *SessionManager) LoginByToken(ctx *Context, req *types.LoginByToken) soa func (s *SessionManager) Logout(ctx *Context, _ *types.Logout) soap.HasFault { session := ctx.Session - delete(s.sessions, session.Key) + s.delSession(session.Key) pc := Map.content().PropertyCollector for ref, obj := range ctx.Session.Registry.objects { @@ -175,11 +203,11 @@ func (s *SessionManager) TerminateSession(ctx *Context, req *types.TerminateSess body.Fault_ = Fault("", new(types.InvalidArgument)) return body } - if _, ok := s.sessions[id]; !ok { + if _, ok := s.getSession(id); !ok { body.Fault_ = Fault("", new(types.NotFound)) return body } - delete(s.sessions, id) + s.delSession(id) } body.Res = new(types.TerminateSessionResponse) @@ -196,7 +224,7 @@ func (s *SessionManager) SessionIsActive(ctx *Context, req *types.SessionIsActiv body.Res = new(types.SessionIsActiveResponse) - if session, exists := s.sessions[req.SessionID]; exists { + if session, exists := s.getSession(req.SessionID); exists { body.Res.Returnval = session.UserName == req.UserName } @@ -206,7 +234,7 @@ func (s *SessionManager) SessionIsActive(ctx *Context, req *types.SessionIsActiv func (s *SessionManager) AcquireCloneTicket(ctx *Context, _ *types.AcquireCloneTicket) soap.HasFault { session := *ctx.Session session.Key = uuid.New().String() - s.sessions[session.Key] = session + s.putSession(session) return &methods.AcquireCloneTicketBody{ Res: &types.AcquireCloneTicketResponse{ @@ -218,10 +246,10 @@ func (s *SessionManager) AcquireCloneTicket(ctx *Context, _ *types.AcquireCloneT func (s *SessionManager) CloneSession(ctx *Context, ticket *types.CloneSession) soap.HasFault { body := new(methods.CloneSessionBody) - session, exists := s.sessions[ticket.CloneTicket] + session, exists := s.getSession(ticket.CloneTicket) if exists { - delete(s.sessions, ticket.CloneTicket) // A clone ticket can only be used once + s.delSession(ticket.CloneTicket) // A clone ticket can only be used once session.Key = uuid.New().String() ctx.SetSession(session, true) @@ -276,12 +304,48 @@ type Context struct { // mapSession maps an HTTP cookie to a Session. func (c *Context) mapSession() { if cookie, err := c.req.Cookie(soap.SessionCookieName); err == nil { - if val, ok := c.svc.sm.sessions[cookie.Value]; ok { + if val, ok := c.svc.sm.getSession(cookie.Value); ok { c.SetSession(val, false) } } } +func (m *SessionManager) expiredSession(id string, now time.Time) bool { + expired := true + + s, ok := m.getSession(id) + if ok { + expired = now.Sub(s.LastActiveTime) > SessionIdleTimeout + if expired { + m.delSession(id) + } + } + + return expired +} + +// SessionIdleWatch starts a goroutine that calls func expired() at SessionIdleTimeout intervals. +// The goroutine exits if the func returns true. +func SessionIdleWatch(ctx context.Context, id string, expired func(string, time.Time) bool) { + if SessionIdleTimeout == 0 { + return + } + + go func() { + for t := time.NewTimer(SessionIdleTimeout); ; { + select { + case <-ctx.Done(): + return + case now := <-t.C: + if expired(id, now) { + return + } + t.Reset(SessionIdleTimeout) + } + } + }() +} + // SetSession should be called after successful authentication. func (c *Context) SetSession(session Session, login bool) { session.UserAgent = c.req.UserAgent() @@ -289,7 +353,7 @@ func (c *Context) SetSession(session Session, login bool) { session.LastActiveTime = time.Now() session.CallCount++ - c.svc.sm.sessions[session.Key] = session + c.svc.sm.putSession(session) c.Session = &session if login { @@ -304,6 +368,8 @@ func (c *Context) SetSession(session Session, login bool) { UserAgent: session.UserAgent, Locale: session.Locale, }) + + SessionIdleWatch(c.Context, session.Key, c.svc.sm.expiredSession) } } @@ -365,9 +431,11 @@ func (s *Session) Get(ref types.ManagedObjectReference) mo.Reference { m.CurrentSession = &s.UserSession // TODO: we could maintain SessionList as part of the SessionManager singleton + sessionMutex.Lock() for _, session := range m.sessions { m.SessionList = append(m.SessionList, session.UserSession) } + sessionMutex.Unlock() return &m case "PropertyCollector": diff --git a/vapi/simulator/simulator.go b/vapi/simulator/simulator.go index ff9ebdf0f..830ded871 100644 --- a/vapi/simulator/simulator.go +++ b/vapi/simulator/simulator.go @@ -361,6 +361,20 @@ func Decode(r *http.Request, w http.ResponseWriter, val interface{}) bool { return true } +func (s *handler) expiredSession(id string, now time.Time) bool { + expired := true + s.Lock() + session, ok := s.Session[id] + if ok { + expired = now.Sub(session.LastAccessed) > simulator.SessionIdleTimeout + if expired { + delete(s.Session, id) + } + } + s.Unlock() + return expired +} + func (s *handler) session(w http.ResponseWriter, r *http.Request) { id := r.Header.Get(internal.SessionCookieName) useHeaderAuthn := strings.ToLower(r.Header.Get(internal.UseHeaderAuthn)) @@ -383,6 +397,7 @@ func (s *handler) session(w http.ResponseWriter, r *http.Request) { id = uuid.New().String() now := time.Now() s.Session[id] = &rest.Session{User: user, Created: now, LastAccessed: now} + simulator.SessionIdleWatch(context.Background(), id, s.expiredSession) if useHeaderAuthn != "true" { http.SetCookie(w, &http.Cookie{ Name: internal.SessionCookieName,