blob: ccfbd75287bb8b4b248bf8c00b2b8b4f1999e171 [file]
// Copyright 2021 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package main
import (
"context"
"net"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"infra/cros/fleet/access"
)
// A sessionContext wraps a session struct with the cancellation context.
// A sessionContext struct is safe to copy.
type sessionContext struct {
ctx context.Context
cancelFunc context.CancelFunc
cancelTimer *time.Timer
session
}
// A session contains the data needed to track a session.
// A session struct is safe to copy.
type session struct {
listener net.Listener
grpcServer *grpc.Server
tlwServer wiringServer
expire time.Time
}
// toProto converts the internal session representation to the public
// protobuf representation.
func (s session) toProto(name string) *access.Session {
return &access.Session{
Name: name,
TlwAddress: s.listener.Addr().String(),
ExpireTime: timestamppb.New(s.expire),
}
}
// wiringServer is the interface for a wiring server (TLW) that is created for a
// session.
type wiringServer interface {
registerWith(*grpc.Server)
Close()
}
type wiringBuilder interface {
build() (wiringServer, error)
}
type fleetTLWBuilder struct {
ufsService string
proxySSHSigner ssh.Signer
serviceAcctJSON string
}
func (b fleetTLWBuilder) build() (wiringServer, error) {
return newTLWServer(b.ufsService, b.proxySSHSigner, b.serviceAcctJSON)
}
type sessionServer struct {
access.UnimplementedFleetServer
wg sync.WaitGroup
wiringBuilder wiringBuilder
muSessions sync.Mutex
// sessions intentionally doesn't use pointers to make
// concurrent ownership easier to reason about.
sessions map[string]sessionContext
}
// newSessionServer creates a new sessionServer.
// It should be closed after use.
// The gRPC server should be stopped first to ensure there are no new requests.
func newSessionServer(b wiringBuilder) *sessionServer {
return &sessionServer{
wiringBuilder: b,
sessions: make(map[string]sessionContext),
}
}
func (s *sessionServer) registerWith(g *grpc.Server) {
access.RegisterFleetServer(g, s)
}
func (s *sessionServer) Close() {
s.muSessions.Lock()
for name, sc := range s.sessions {
sc.cancelFunc()
delete(s.sessions, name)
}
s.muSessions.Unlock()
s.wg.Wait()
}
// CreateSession implements access.FleetServer.
func (s *sessionServer) CreateSession(ctx context.Context, req *access.CreateSessionRequest) (*access.Session, error) {
reqSes := req.GetSession()
l, err := net.Listen("tcp", "0.0.0.0:0")
if err != nil {
return nil, status.Errorf(codes.FailedPrecondition, err.Error())
}
gs := grpc.NewServer()
tlw, err := s.wiringBuilder.build()
if err != nil {
return nil, status.Errorf(codes.FailedPrecondition, err.Error())
}
tlw.registerWith(gs)
// This goroutine is stopped by the session cancellation that
// is set up below.
go gs.Serve(l)
sc := s.setupSessionContext(session{
listener: l,
grpcServer: gs,
tlwServer: tlw,
expire: reqSes.GetExpireTime().AsTime(),
})
name := "sessions/" + uuid.New().String()
s.muSessions.Lock()
s.sessions[name] = sc
s.muSessions.Unlock()
return sc.toProto(name), nil
}
// setupSessionContext sets up the cancellation context for a new session.
func (s *sessionServer) setupSessionContext(ses session) sessionContext {
sc := sessionContext{session: ses}
sc.ctx, sc.cancelFunc = context.WithCancel(context.Background())
sc.cancelTimer = time.NewTimer(ses.expire.Sub(time.Now()))
s.wg.Add(1)
go func() {
defer s.wg.Done()
select {
case <-sc.ctx.Done():
case <-sc.cancelTimer.C:
}
// Not graceful, the session needs to die now.
sc.grpcServer.Stop()
sc.tlwServer.Close()
// Make sure both the context and timer resources are
// freed regardless of how we got the cancellation.
sc.cancelFunc()
sc.cancelTimer.Stop()
}()
return sc
}
// GetSession implements access.FleetServer.
func (s *sessionServer) GetSession(ctx context.Context, req *access.GetSessionRequest) (*access.Session, error) {
name := req.GetName()
s.muSessions.Lock()
sc, ok := s.sessions[name]
s.muSessions.Unlock()
if !ok {
return nil, status.Errorf(codes.NotFound, "no session named %s", name)
}
return sc.toProto(name), nil
}
// UpdateSession implements access.FleetServer.
func (s *sessionServer) UpdateSession(ctx context.Context, req *access.UpdateSessionRequest) (*access.Session, error) {
ses := req.GetSession()
name := ses.GetName()
s.muSessions.Lock()
defer s.muSessions.Unlock()
sc, ok := s.sessions[name]
if !ok {
return nil, status.Errorf(codes.NotFound, "no session named %s", name)
}
var (
badPaths []string
updateTimer bool
)
for _, p := range req.GetUpdateMask().GetPaths() {
switch p {
case "expire_time":
sc.expire = ses.GetExpireTime().AsTime()
// We can't update the timer until we are sure
// there are no errors, so we set a flag for
// later.
updateTimer = true
default:
badPaths = append(badPaths)
}
}
if len(badPaths) > 0 {
return nil, status.Errorf(codes.InvalidArgument, "bad update_mask paths: %v", badPaths)
}
if updateTimer {
if !sc.cancelTimer.Stop() {
return nil, status.Errorf(codes.Aborted, "session already expired")
}
sc.cancelTimer.Reset(sc.expire.Sub(time.Now()))
}
s.sessions[name] = sc
return sc.toProto(name), nil
}
// DeleteSession implements access.FleetServer.
func (s *sessionServer) DeleteSession(ctx context.Context, req *access.DeleteSessionRequest) (*emptypb.Empty, error) {
name := req.GetName()
s.muSessions.Lock()
sc, ok := s.sessions[name]
delete(s.sessions, name)
s.muSessions.Unlock()
if !ok {
return nil, status.Errorf(codes.NotFound, "no session named %s", name)
}
sc.cancelFunc()
return &emptypb.Empty{}, nil
}