Add static and dynamic web ui configuration options (#21945)

* Add UiConfig option to proxy service config

* Add uiConfig to GRV_CONFIG window object and use in terminal constructor

* Update configuration reference docs

* Remove extra structs and change property to scrollbackLines

* Update web/packages/teleport/src/config.ts

Co-authored-by: Jeff Pihach <hatched@users.noreply.github.com>

* Use a common struct and apply in applyProxyConfig

* Remove webclient dependency for fileconf

* Add missing doc comments

* Set individual properties of cfg.Proxy.UI

* Remove unneeded comment

* Add nolint rule

* Fix nolint

* Add test for UIConfig

* Cleanup tests

* Fix TestUIConfig context

* Allow dynamic config for web ui (#22097)

* Update protofiles

---------

Co-authored-by: Jeff Pihach <hatched@users.noreply.github.com>
This commit is contained in:
Michael 2023-02-28 11:45:52 -06:00 committed by GitHub
parent 880736463b
commit d94fed7b0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 3237 additions and 2026 deletions

View file

@ -2265,6 +2265,27 @@ func (c *Client) GetInstallers(ctx context.Context) ([]types.Installer, error) {
return installers, nil
}
// GetUIConfig gets the configuration for the UI served by the proxy service
func (c *Client) GetUIConfig(ctx context.Context) (types.UIConfig, error) {
resp, err := c.grpc.GetUIConfig(ctx, &emptypb.Empty{}, c.callOpts...)
return resp, trail.FromGRPC(err)
}
// SetUIConfig sets the configuration for the UI served by the proxy service
func (c *Client) SetUIConfig(ctx context.Context, uic types.UIConfig) error {
uicV1, ok := uic.(*types.UIConfigV1)
if !ok {
return trace.BadParameter("invalid type %T", uic)
}
_, err := c.grpc.SetUIConfig(ctx, uicV1, c.callOpts...)
return trail.FromGRPC(err)
}
func (c *Client) DeleteUIConfig(ctx context.Context) error {
_, err := c.grpc.DeleteUIConfig(ctx, &emptypb.Empty{}, c.callOpts...)
return trail.FromGRPC(err)
}
// GetInstaller gets the cluster installer resource
func (c *Client) GetInstaller(ctx context.Context, name string) (types.Installer, error) {
resp, err := c.grpc.GetInstaller(ctx, &types.ResourceRequest{Name: name}, c.callOpts...)

View file

@ -171,6 +171,10 @@ func EventToGRPC(in types.Event) (*proto.Event, error) {
out.Resource = &proto.Event_Installer{
Installer: r,
}
case *types.UIConfigV1:
out.Resource = &proto.Event_UIConfig{
UIConfig: r,
}
case *types.DatabaseServiceV1:
out.Resource = &proto.Event_DatabaseService{
DatabaseService: r,
@ -311,6 +315,9 @@ func EventFromGRPC(in proto.Event) (*types.Event, error) {
} else if r := in.GetInstaller(); r != nil {
out.Resource = r
return &out, nil
} else if r := in.GetUIConfig(); r != nil {
out.Resource = r
return &out, nil
} else if r := in.GetDatabaseService(); r != nil {
out.Resource = r
return &out, nil

File diff suppressed because it is too large Load diff

View file

@ -55,12 +55,20 @@ type WebConfig struct {
TunnelPublicAddress string `json:"tunnelPublicAddress,omitempty"`
// RecoveryCodesEnabled is a flag that determines if recovery codes are enabled in the cluster.
RecoveryCodesEnabled bool `json:"recoveryCodesEnabled,omitempty"`
// UIConfig is the configuration for the web UI
UI UIConfig `json:"ui,omitempty"`
// IsDashboard is a flag that determines if the cluster is running as a "dashboard".
// The web UI for dashboards provides functionality for downloading self-hosted licenses and
// Teleport Enterprise binaries.
IsDashboard bool `json:"isDashboard,omitempty"`
}
// UIConfig provides config options for the web UI served by the proxy service.
type UIConfig struct {
// ScrollbackLines is the max number of lines the UI terminal can display in its history
ScrollbackLines int `json:"scrollbackLines,omitempty"` //nolint:unused // marshaled in config/configuration.go for WebConfig
}
// WebConfigAuthProvider describes auth. provider
type WebConfigAuthProvider struct {
// Name is this provider ID

View file

@ -124,6 +124,8 @@ message Event {
types.WebSessionV2 SAMLIdPSession = 37 [(gogoproto.jsontag) = "saml_idp_session,omitempty"];
// UserGroup is a UserGroup resource
types.UserGroupV1 UserGroup = 38 [(gogoproto.jsontag) = "user_group,omitempty"];
// UIConfig provides a way for users to adjust settings of the UI served by the proxy service.
types.UIConfigV1 UIConfig = 39 [(gogoproto.jsontag) = "ui_config,omitempty"];
}
}
@ -2604,6 +2606,13 @@ service AuthService {
// ResetAuthPreference resets cluster auth preference to defaults.
rpc ResetAuthPreference(google.protobuf.Empty) returns (google.protobuf.Empty);
// GetUIConfig gets the configuration for the UI served by the proxy service
rpc GetUIConfig(google.protobuf.Empty) returns (types.UIConfigV1);
// SetUIConfig sets the configuration for the UI served by the proxy service
rpc SetUIConfig(types.UIConfigV1) returns (google.protobuf.Empty);
// DeleteUIConfig deletes the custom configuration for the UI served by the proxy service
rpc DeleteUIConfig(google.protobuf.Empty) returns (google.protobuf.Empty);
// GetEvents gets events from the audit log.
rpc GetEvents(GetEventsRequest) returns (Events);
// GetSessionEvents gets completed session events from the audit log.

View file

@ -4595,6 +4595,26 @@ message Participant {
];
}
// UIConfigV1 represents the configuration for the web UI served by the proxy service
message UIConfigV1 {
// Header is the resource header for the UI configuration.
ResourceHeader Header = 1 [
(gogoproto.nullable) = false,
(gogoproto.jsontag) = "",
(gogoproto.embed) = true
];
// Spec is the resource spec.
UIConfigSpecV1 Spec = 5 [
(gogoproto.nullable) = false,
(gogoproto.jsontag) = "spec"
];
}
// UIConfigSpecV1 is the specification for a UIConfig
message UIConfigSpecV1 {
int32 ScrollbackLines = 1 [(gogoproto.jsontag) = "scrollback_lines"];
}
// InstallerV1 represents an installer script resource. Used to
// provide a script to install teleport on discovered nodes.
message InstallerV1 {

View file

@ -198,6 +198,10 @@ const (
// cluster audit configuration.
MetaNameClusterAuditConfig = "cluster-audit-config"
// MetaNameUIConfig is the exact name of the singleton resource holding
// proxy service UI configuration.
MetaNameUIConfig = "ui-config"
// KindClusterNetworkingConfig is the resource that holds cluster networking configuration.
KindClusterNetworkingConfig = "cluster_networking_config"
@ -290,6 +294,10 @@ const (
// used to install teleport on discovered nodes
KindInstaller = "installer"
// KindUIConfig is a resource that holds configuration for the UI
// served by the proxy service
KindUIConfig = "ui_config"
// KindClusterAlert is a resource that conveys a cluster-level alert message.
KindClusterAlert = "cluster_alert"

File diff suppressed because it is too large Load diff

128
api/types/ui_config.go Normal file
View file

@ -0,0 +1,128 @@
/**
* Copyright 2023 Gravitational, Inc.
*
* 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 types
import (
"time"
"github.com/gravitational/trace"
)
// UIConfig defines configuration for the web UI served
// by the proxy service. This is a configuration resource,
// never create more than one instance of it.
type UIConfig interface {
Resource
// GetScript returns the contents of the installer script
GetScrollbackLines() int32
// SetScript sets the installer script
SetScrollbackLines(int32)
String() string
}
func NewUIConfigV1() (*UIConfigV1, error) {
uiconfig := &UIConfigV1{
ResourceHeader: ResourceHeader{},
}
if err := uiconfig.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
return uiconfig, nil
}
// CheckAndSetDefaults verifies the constraints for UIConfig.
func (c *UIConfigV1) CheckAndSetDefaults() error {
c.setStaticFields()
if err := c.Metadata.CheckAndSetDefaults(); err != nil {
return trace.Wrap(err)
}
if c.Spec.ScrollbackLines < 0 {
return trace.BadParameter("invalid scrollback lines value. Must be greater than or equal to 0.")
}
return nil
}
// GetVersion returns resource version.
func (c *UIConfigV1) GetVersion() string {
return c.Version
}
// GetName returns the name of the resource.
func (c *UIConfigV1) GetName() string {
return c.Metadata.Name
}
// SetName sets the name of the resource.
func (c *UIConfigV1) SetName(e string) {
c.Metadata.Name = e
}
// SetExpiry sets expiry time for the object.
func (c *UIConfigV1) SetExpiry(expires time.Time) {
c.Metadata.SetExpiry(expires)
}
// Expiry returns object expiry setting.
func (c *UIConfigV1) Expiry() time.Time {
return c.Metadata.Expiry()
}
// GetMetadata returns object metadata.
func (c *UIConfigV1) GetMetadata() Metadata {
return c.Metadata
}
// GetResourceID returns resource ID.
func (c *UIConfigV1) GetResourceID() int64 {
return c.Metadata.ID
}
// SetResourceID sets resource ID.
func (c *UIConfigV1) SetResourceID(id int64) {
c.Metadata.ID = id
}
// GetKind returns resource kind.
func (c *UIConfigV1) GetKind() string {
return c.Kind
}
// GetSubKind returns resource subkind.
func (c *UIConfigV1) GetSubKind() string {
return c.SubKind
}
// SetSubKind sets resource subkind.
func (c *UIConfigV1) SetSubKind(sk string) {
c.SubKind = sk
}
func (c *UIConfigV1) GetScrollbackLines() int32 {
return c.Spec.ScrollbackLines
}
func (c *UIConfigV1) SetScrollbackLines(lines int32) {
c.Spec.ScrollbackLines = lines
}
// setStaticFields sets static resource header and metadata fields.
func (c *UIConfigV1) setStaticFields() {
c.Kind = KindUIConfig
c.Version = V1
}

View file

@ -188,6 +188,9 @@ type ReadProxyAccessPoint interface {
// GetSessionRecordingConfig returns session recording configuration.
GetSessionRecordingConfig(ctx context.Context, opts ...services.MarshalOption) (types.SessionRecordingConfig, error)
// GetUIConfig returns configuration for the UI served by the proxy service
GetUIConfig(ctx context.Context) (types.UIConfig, error)
// GetRole returns role by name
GetRole(ctx context.Context, name string) (types.Role, error)
@ -918,6 +921,9 @@ type Cache interface {
// ListWindowsDesktopServices returns a paginated list of windows desktops.
ListWindowsDesktopServices(ctx context.Context, req types.ListWindowsDesktopServicesRequest) (*types.ListWindowsDesktopServicesResponse, error)
// GetUIConfig gets the config for the UI served by the proxy service
GetUIConfig(ctx context.Context) (types.UIConfig, error)
// GetInstaller gets installer resource for this cluster
GetInstaller(ctx context.Context, name string) (types.Installer, error)

View file

@ -3616,6 +3616,28 @@ func (a *ServerWithRoles) GetAuthPreference(ctx context.Context) (types.AuthPref
return a.authServer.GetAuthPreference(ctx)
}
func (a *ServerWithRoles) GetUIConfig(ctx context.Context) (types.UIConfig, error) {
if err := a.action(apidefaults.Namespace, types.KindUIConfig, types.VerbRead); err != nil {
return nil, trace.Wrap(err)
}
cfg, err := a.authServer.GetUIConfig(ctx)
return cfg, trace.Wrap(err)
}
func (a *ServerWithRoles) SetUIConfig(ctx context.Context, uic types.UIConfig) error {
if err := a.action(apidefaults.Namespace, types.KindUIConfig, types.VerbUpdate, types.VerbCreate); err != nil {
return trace.Wrap(err)
}
return trace.Wrap(a.authServer.SetUIConfig(ctx, uic))
}
func (a *ServerWithRoles) DeleteUIConfig(ctx context.Context) error {
if err := a.action(apidefaults.Namespace, types.KindUIConfig, types.VerbDelete); err != nil {
return trace.Wrap(err)
}
return trace.Wrap(a.authServer.DeleteUIConfig(ctx))
}
// GetInstaller retrieves an installer script resource
func (a *ServerWithRoles) GetInstaller(ctx context.Context, name string) (types.Installer, error) {
if err := a.action(apidefaults.Namespace, types.KindInstaller, types.VerbRead); err != nil {

View file

@ -4500,6 +4500,45 @@ func (g *GRPCServer) SetInstaller(ctx context.Context, req *types.InstallerV1) (
return &emptypb.Empty{}, nil
}
func (g *GRPCServer) SetUIConfig(ctx context.Context, req *types.UIConfigV1) (*emptypb.Empty, error) {
// TODO (avatus) send an audit event when SetUIConfig is called
auth, err := g.authenticate(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
if err := auth.SetUIConfig(ctx, req); err != nil {
return nil, trace.Wrap(err)
}
return &emptypb.Empty{}, nil
}
func (g *GRPCServer) GetUIConfig(ctx context.Context, _ *emptypb.Empty) (*types.UIConfigV1, error) {
auth, err := g.authenticate(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
uiconfig, err := auth.ServerWithRoles.GetUIConfig(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
uiconfigv1, ok := uiconfig.(*types.UIConfigV1)
if !ok {
return nil, trace.BadParameter("unexpected type %T", uiconfig)
}
return uiconfigv1, nil
}
func (g *GRPCServer) DeleteUIConfig(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
auth, err := g.authenticate(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
if err := auth.DeleteUIConfig(ctx); err != nil {
return nil, trace.Wrap(err)
}
return &emptypb.Empty{}, nil
}
// GetInstaller retrieves the installer script resource
func (g *GRPCServer) GetInstaller(ctx context.Context, req *types.ResourceRequest) (*types.InstallerV1, error) {
auth, err := g.authenticate(ctx)

View file

@ -400,6 +400,7 @@ func (a *authorizer) authorizeRemoteBuiltinRole(r RemoteBuiltinRole) (*Context,
types.NewRule(types.KindKubeService, services.RO()),
types.NewRule(types.KindKubeServer, services.RO()),
types.NewRule(types.KindInstaller, services.RO()),
types.NewRule(types.KindUIConfig, services.RO()),
types.NewRule(types.KindDatabaseService, services.RO()),
// this rule allows remote proxy to update the cluster's certificate authorities
// during certificates renewal
@ -477,6 +478,7 @@ func roleSpecForProxy(clusterName string) types.RoleSpecV6 {
types.NewRule(types.KindClusterAuditConfig, services.RO()),
types.NewRule(types.KindClusterNetworkingConfig, services.RO()),
types.NewRule(types.KindSessionRecordingConfig, services.RO()),
types.NewRule(types.KindUIConfig, services.RO()),
types.NewRule(types.KindStaticTokens, services.RO()),
types.NewRule(types.KindTunnelConnection, services.RW()),
types.NewRule(types.KindRemoteCluster, services.RO()),

16
lib/cache/cache.go vendored
View file

@ -78,6 +78,7 @@ func ForAuth(cfg Config) Config {
{Kind: types.KindClusterNetworkingConfig},
{Kind: types.KindClusterAuthPreference},
{Kind: types.KindSessionRecordingConfig},
{Kind: types.KindUIConfig},
{Kind: types.KindStaticTokens},
{Kind: types.KindToken},
{Kind: types.KindUser},
@ -126,6 +127,7 @@ func ForProxy(cfg Config) Config {
{Kind: types.KindClusterNetworkingConfig},
{Kind: types.KindClusterAuthPreference},
{Kind: types.KindSessionRecordingConfig},
{Kind: types.KindUIConfig},
{Kind: types.KindUser},
{Kind: types.KindRole},
{Kind: types.KindNamespace},
@ -1562,6 +1564,20 @@ func (c *Cache) GetClusterName(opts ...services.MarshalOption) (types.ClusterNam
return rg.clusterConfig.GetClusterName(opts...)
}
func (c *Cache) GetUIConfig(ctx context.Context) (types.UIConfig, error) {
ctx, span := c.Tracer.Start(ctx, "cache/GetUIConfig")
defer span.End()
rg, err := c.read()
if err != nil {
return nil, trace.Wrap(err)
}
defer rg.Release()
uiconfig, err := rg.clusterConfig.GetUIConfig(ctx)
return uiconfig, trace.Wrap(err)
}
// GetInstaller gets the installer script resource for the cluster
func (c *Cache) GetInstaller(ctx context.Context, name string) (types.Installer, error) {
ctx, span := c.Tracer.Start(ctx, "cache/GetInstaller")

View file

@ -2161,6 +2161,7 @@ func TestCacheWatchKindExistsInEvents(t *testing.T) {
types.KindClusterNetworkingConfig: types.DefaultClusterNetworkingConfig(),
types.KindClusterAuthPreference: types.DefaultAuthPreference(),
types.KindSessionRecordingConfig: types.DefaultSessionRecordingConfig(),
types.KindUIConfig: &types.UIConfigV1{},
types.KindStaticTokens: &types.StaticTokensV2{},
types.KindToken: &types.ProvisionTokenV2{},
types.KindUser: &types.UserV2{},

View file

@ -94,6 +94,11 @@ func setupCollections(c *Cache, watches []types.WatchKind) (map[resourceKind]col
return nil, trace.BadParameter("missing parameter ClusterConfig")
}
collections[resourceKind] = &installerConfig{watch: watch, Cache: c}
case types.KindUIConfig:
if c.ClusterConfig == nil {
return nil, trace.BadParameter("missing parameter ClusterConfig")
}
collections[resourceKind] = &uiConfig{watch: watch, Cache: c}
case types.KindUser:
if c.Users == nil {
return nil, trace.BadParameter("missing parameter Users")
@ -2294,6 +2299,74 @@ func (c *clusterNetworkingConfig) watchKind() types.WatchKind {
return c.watch
}
type uiConfig struct {
*Cache
watch types.WatchKind
}
func (c *uiConfig) erase(ctx context.Context) error {
if err := c.clusterConfigCache.DeleteUIConfig(ctx); err != nil {
if !trace.IsNotFound(err) {
return trace.Wrap(err)
}
}
return nil
}
func (c *uiConfig) fetch(ctx context.Context) (apply func(ctx context.Context) error, err error) {
var noConfig bool
resource, err := c.ClusterConfig.GetUIConfig(ctx)
if err != nil {
if !trace.IsNotFound(err) {
return nil, trace.Wrap(err)
}
noConfig = true
}
return func(ctx context.Context) error {
// either zero or one instance exists, so we either erase or
// update, but not both.
if noConfig {
if err := c.erase(ctx); err != nil {
return trace.Wrap(err)
}
return nil
}
if err := c.clusterConfigCache.SetUIConfig(ctx, resource); err != nil {
return trace.Wrap(err)
}
return nil
}, nil
}
func (c *uiConfig) processEvent(ctx context.Context, event types.Event) error {
switch event.Type {
case types.OpDelete:
err := c.clusterConfigCache.DeleteUIConfig(ctx)
if err != nil {
if !trace.IsNotFound(err) {
c.Logger.WithError(err).Warn("Failed to delete resource.")
return trace.Wrap(err)
}
}
case types.OpPut:
resource, ok := event.Resource.(types.UIConfig)
if !ok {
return trace.BadParameter("unexpected type %T", event.Resource)
}
if err := c.clusterConfigCache.SetUIConfig(ctx, resource); err != nil {
return trace.Wrap(err)
}
default:
c.Logger.WithField("event", event.Type).Warn("Skipping unsupported event type.")
}
return nil
}
func (c *uiConfig) watchKind() types.WatchKind {
return c.watch
}
type sessionRecordingConfig struct {
*Cache
watch types.WatchKind

View file

@ -40,6 +40,7 @@ import (
kyaml "k8s.io/apimachinery/pkg/util/yaml"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/client/webclient"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib"
@ -872,6 +873,10 @@ func applyProxyConfig(fc *FileConfig, cfg *service.Config) error {
cfg.Proxy.PeerAddr = *addr
}
if fc.Proxy.UI != nil {
cfg.Proxy.UI = webclient.UIConfig(*fc.Proxy.UI)
}
// This is the legacy format. Continue to support it forever, but ideally
// users now use the list format below.
if fc.Proxy.KeyFile != "" || fc.Proxy.CertFile != "" {

View file

@ -1880,6 +1880,15 @@ type Proxy struct {
//
//nolint:revive // Because we want this to be IdP.
IdP IdP `yaml:"idp,omitempty"`
// UI provides config options for the web UI
UI *UIConfig `yaml:"ui,omitempty"`
}
// UIConfig provides config options for the web UI served by the proxy service.
type UIConfig struct {
// ScrollbackLines is the max number of lines the UI terminal can display in its history
ScrollbackLines int `yaml:"scrollback_lines,omitempty"`
}
// ACME configures ACME protocol - automatic X.509 certificates

View file

@ -41,6 +41,7 @@ import (
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/breaker"
"github.com/gravitational/teleport/api/client/webclient"
"github.com/gravitational/teleport/api/types"
azureutils "github.com/gravitational/teleport/api/utils/azure"
"github.com/gravitational/teleport/lib/auth"
@ -524,6 +525,9 @@ type ProxyConfig struct {
// DisableALPNSNIListener allows turning off the ALPN Proxy listener. Used in tests.
DisableALPNSNIListener bool
// UI provides config options for the web UI
UI webclient.UIConfig
}
// ACME configures ACME automatic certificate renewal

View file

@ -3606,6 +3606,7 @@ func (process *TeleportProcess) initProxyEndpoint(conn *Connector) error {
Context: process.ExitContext(),
StaticFS: fs,
ClusterFeatures: process.getClusterFeatures(),
UI: cfg.Proxy.UI,
ProxySettings: proxySettings,
PublicProxyAddr: process.proxyPublicAddr().Addr,
ALPNHandler: alpnHandlerForWeb.HandleConnection,

View file

@ -43,6 +43,13 @@ type ClusterConfiguration interface {
// DeleteStaticTokens deletes static tokens resource
DeleteStaticTokens() error
// GetUIConfig gets the proxy service UI config from the backend
GetUIConfig(context.Context) (types.UIConfig, error)
// SetUIConfig sets the proxy service UI config from the backend
SetUIConfig(context.Context, types.UIConfig) error
// DeleteUIConfig deletes the proxy service UI config from the backend
DeleteUIConfig(ctx context.Context) error
// GetAuthPreference gets types.AuthPreference from the backend.
GetAuthPreference(context.Context) (types.AuthPreference, error)
// SetAuthPreference sets types.AuthPreference from the backend.

View file

@ -379,6 +379,31 @@ func (s *ClusterConfigurationService) GetInstallers(ctx context.Context) ([]type
return installers, nil
}
func (s *ClusterConfigurationService) GetUIConfig(ctx context.Context) (types.UIConfig, error) {
item, err := s.Get(ctx, backend.Key(clusterConfigPrefix, uiPrefix))
if err != nil {
return nil, trace.Wrap(err)
}
return services.UnmarshalUIConfig(item.Value)
}
func (s *ClusterConfigurationService) SetUIConfig(ctx context.Context, uic types.UIConfig) error {
value, err := services.MarshalUIConfig(uic)
if err != nil {
return trace.Wrap(err)
}
_, err = s.Put(ctx, backend.Item{
Key: backend.Key(clusterConfigPrefix, uiPrefix),
Value: value,
})
return trace.Wrap(err)
}
func (s *ClusterConfigurationService) DeleteUIConfig(ctx context.Context) error {
return trace.Wrap(s.Delete(ctx, backend.Key(clusterConfigPrefix, uiPrefix)))
}
// GetInstaller gets the script of the cluster from the backend.
func (s *ClusterConfigurationService) GetInstaller(ctx context.Context, name string) (types.Installer, error) {
item, err := s.Get(ctx, backend.Key(clusterConfigPrefix, scriptsPrefix, installerPrefix, name))
@ -430,5 +455,6 @@ const (
networkingPrefix = "networking"
sessionRecordingPrefix = "session_recording"
scriptsPrefix = "scripts"
uiPrefix = "ui"
installerPrefix = "installer"
)

View file

@ -72,6 +72,8 @@ func (e *EventsService) NewWatcher(ctx context.Context, watch types.Watch) (type
parser = newAuthPreferenceParser()
case types.KindSessionRecordingConfig:
parser = newSessionRecordingConfigParser()
case types.KindUIConfig:
parser = newUIConfigParser()
case types.KindClusterName:
parser = newClusterNameParser()
case types.KindNamespace:
@ -496,6 +498,40 @@ func (p *authPreferenceParser) parse(event backend.Event) (types.Resource, error
}
}
func newUIConfigParser() *uiConfigParser {
return &uiConfigParser{
baseParser: newBaseParser(backend.Key(clusterConfigPrefix, uiPrefix)),
}
}
type uiConfigParser struct {
baseParser
}
func (p *uiConfigParser) parse(event backend.Event) (types.Resource, error) {
switch event.Type {
case types.OpDelete:
h, err := resourceHeader(event, types.KindUIConfig, types.V1, 0)
if err != nil {
return nil, trace.Wrap(err)
}
h.SetName(types.MetaNameUIConfig)
return h, nil
case types.OpPut:
ap, err := services.UnmarshalUIConfig(
event.Item.Value,
services.WithResourceID(event.Item.ID),
services.WithExpires(event.Item.Expires),
)
if err != nil {
return nil, trace.Wrap(err)
}
return ap, nil
default:
return nil, trace.BadParameter("event %v is not supported", event.Type)
}
}
func newSessionRecordingConfigParser() *sessionRecordingConfigParser {
return &sessionRecordingConfigParser{
baseParser: newBaseParser(backend.Key(clusterConfigPrefix, sessionRecordingPrefix)),

View file

@ -67,6 +67,7 @@ func NewPresetEditorRole() types.Role {
types.NewRule(types.KindClusterName, RW()),
types.NewRule(types.KindClusterNetworkingConfig, RW()),
types.NewRule(types.KindSessionRecordingConfig, RW()),
types.NewRule(types.KindUIConfig, RW()),
types.NewRule(types.KindTrustedCluster, RW()),
types.NewRule(types.KindRemoteCluster, RW()),
types.NewRule(types.KindToken, RW()),

View file

@ -145,6 +145,8 @@ func ParseShortcut(in string) (string, error) {
return types.KindTrustedCluster, nil
case types.KindClusterAuthPreference, "cluster_authentication_preferences", "cap":
return types.KindClusterAuthPreference, nil
case types.KindUIConfig, "ui":
return types.KindUIConfig, nil
case types.KindClusterNetworkingConfig, "networking_config", "networking", "net_config", "netconfig":
return types.KindClusterNetworkingConfig, nil
case types.KindSessionRecordingConfig, "recording_config", "session_recording", "rec_config", "recconfig":

View file

@ -156,6 +156,7 @@ func RoleForUser(u types.User) types.Role {
types.NewRule(types.KindClusterAuthPreference, RW()),
types.NewRule(types.KindClusterNetworkingConfig, RW()),
types.NewRule(types.KindSessionRecordingConfig, RW()),
types.NewRule(types.KindUIConfig, RW()),
types.NewRule(types.KindApp, RW()),
types.NewRule(types.KindDatabase, RW()),
types.NewRule(types.KindLock, RW()),

76
lib/services/ui_config.go Normal file
View file

@ -0,0 +1,76 @@
/**
* Copyright 2023 Gravitational, Inc.
*
* 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 services
import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/utils"
)
// UnmarshalUIConfig unmarshals the UIConfig resource from JSON.
func UnmarshalUIConfig(data []byte, opts ...MarshalOption) (types.UIConfig, error) {
if len(data) == 0 {
return nil, trace.BadParameter("missing resource data")
}
cfg, err := CollectOptions(opts)
if err != nil {
return nil, trace.Wrap(err)
}
var uiconfig types.UIConfigV1
if err := utils.FastUnmarshal(data, &uiconfig); err != nil {
return nil, trace.BadParameter(err.Error())
}
if err := uiconfig.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
if cfg.ID != 0 {
uiconfig.SetResourceID(cfg.ID)
}
if !cfg.Expires.IsZero() {
uiconfig.SetExpiry(cfg.Expires)
}
return &uiconfig, nil
}
// MarshalUIConfig marshals the UIConfig resource to JSON.
func MarshalUIConfig(uiconfig types.UIConfig, opts ...MarshalOption) ([]byte, error) {
if err := uiconfig.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
cfg, err := CollectOptions(opts)
if err != nil {
return nil, trace.Wrap(err)
}
switch uiconfig := uiconfig.(type) {
case *types.UIConfigV1:
if !cfg.PreserveResourceID {
// avoid modifying the original object
// to prevent unexpected data races
copy := *uiconfig
copy.SetResourceID(0)
uiconfig = &copy
}
return utils.FastMarshal(uiconfig)
default:
return nil, trace.BadParameter("unrecognized uiconfig version %T", uiconfig)
}
}

View file

@ -243,6 +243,9 @@ type Config struct {
// HealthCheckAppServer is a function that checks if the proxy can handle
// application requests.
HealthCheckAppServer healthCheckAppServerFunc
// UI provides config options for the web UI
UI webclient.UIConfig
}
type APIHandler struct {
@ -1303,6 +1306,7 @@ func (h *Handler) getWebConfig(w http.ResponseWriter, r *http.Request, p httprou
IsCloud: h.ClusterFeatures.GetCloud(),
TunnelPublicAddress: tunnelPublicAddr,
RecoveryCodesEnabled: h.ClusterFeatures.GetRecoveryCodes(),
UI: h.getUIConfig(r.Context()),
IsDashboard: isDashboard(h.ClusterFeatures),
}
@ -1327,6 +1331,18 @@ type JWKSResponse struct {
Keys []jwt.JWK `json:"keys"`
}
// getUiConfig will first try to get an ui_config set in the cache and then
// return what was set by the file config. Returns nil if neither are set which
// is fine, as the web UI can set its own defaults.
func (h *Handler) getUIConfig(ctx context.Context) webclient.UIConfig {
if uiConfig, err := h.cfg.AccessPoint.GetUIConfig(ctx); err == nil && uiConfig != nil {
return webclient.UIConfig{
ScrollbackLines: int(uiConfig.GetScrollbackLines()),
}
}
return h.cfg.UI
}
// jwks returns all public keys used to sign JWT tokens for this cluster.
func (h *Handler) jwks(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
clusterName, err := h.cfg.ProxyClient.GetDomainName(r.Context())

View file

@ -171,6 +171,8 @@ type webSuiteConfig struct {
disableDiskBasedRecording bool
uiConfig webclient.UIConfig
// Custom "HealthCheckAppServer" function. Can be used to avoid dialing app
// services.
HealthCheckAppServer healthCheckAppServerFunc
@ -443,6 +445,7 @@ func newWebSuiteWithConfig(t *testing.T, cfg webSuiteConfig) *WebSuite {
SessionControl: proxySessionController,
Router: router,
HealthCheckAppServer: cfg.HealthCheckAppServer,
UI: cfg.uiConfig,
}
if handlerConfig.HealthCheckAppServer == nil {
@ -1237,6 +1240,30 @@ func TestNewTerminalHandler(t *testing.T) {
require.NotNil(t, term.log)
}
func TestUIConfig(t *testing.T) {
uiConfig := webclient.UIConfig{
ScrollbackLines: 555,
}
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
s := newWebSuiteWithConfig(t, webSuiteConfig{uiConfig: uiConfig})
clt := s.client(t)
endpoint := clt.Endpoint("web", "config.js")
re, err := clt.Get(ctx, endpoint, nil)
require.NoError(t, err)
require.True(t, strings.HasPrefix(string(re.Bytes()), "var GRV_CONFIG"))
t.Cleanup(cancel)
// Response is type application/javascript, we need to strip off the variable name
// and the semicolon at the end, then we are left with json like object.
var cfg webclient.WebConfig
str := strings.ReplaceAll(string(re.Bytes()), "var GRV_CONFIG = ", "")
err = json.Unmarshal([]byte(str[:len(str)-1]), &cfg)
require.NoError(t, err)
require.Equal(t, uiConfig, cfg.UI)
}
func TestResizeTerminal(t *testing.T) {
t.Parallel()
s := newWebSuiteWithConfig(t, webSuiteConfig{disableDiskBasedRecording: true})

View file

@ -155,8 +155,8 @@ func (s *serverCollection) writeText(w io.Writer) error {
t = asciitable.MakeTable(headers, rows...)
} else {
t = asciitable.MakeTableWithTruncatedColumn(headers, rows, "Labels")
}
_, err := t.AsBuffer().WriteTo(w)
return trace.Wrap(err)
}
@ -552,6 +552,21 @@ func (c *authPrefCollection) writeText(w io.Writer) error {
return trace.Wrap(err)
}
type uiConfigCollection struct {
uiconfig types.UIConfig
}
func (c *uiConfigCollection) resources() (r []types.Resource) {
return []types.Resource{c.uiconfig}
}
func (c *uiConfigCollection) writeText(w io.Writer) error {
t := asciitable.MakeTable([]string{"Scrollback Lines"})
t.AddRow([]string{string(c.uiconfig.GetScrollbackLines())})
_, err := t.AsBuffer().WriteTo(w)
return trace.Wrap(err)
}
type netConfigCollection struct {
netConfig types.ClusterNetworkingConfig
}

View file

@ -109,6 +109,7 @@ func (rc *ResourceCommand) Initialize(app *kingpin.Application, config *service.
types.KindClusterAuthPreference: rc.createAuthPreference,
types.KindClusterNetworkingConfig: rc.createClusterNetworkingConfig,
types.KindSessionRecordingConfig: rc.createSessionRecordingConfig,
types.KindUIConfig: rc.createUIConfig,
types.KindLock: rc.createLock,
types.KindNetworkRestrictions: rc.createNetworkRestrictions,
types.KindApp: rc.createApp,
@ -639,6 +640,15 @@ func (rc *ResourceCommand) createInstaller(ctx context.Context, client auth.Clie
return trace.Wrap(err)
}
func (rc *ResourceCommand) createUIConfig(ctx context.Context, client auth.ClientI, raw services.UnknownResource) error {
uic, err := services.UnmarshalUIConfig(raw.Raw)
if err != nil {
return trace.Wrap(err)
}
return trace.Wrap(client.SetUIConfig(ctx, uic))
}
func (rc *ResourceCommand) createNode(ctx context.Context, client auth.ClientI, raw services.UnknownResource) error {
server, err := services.UnmarshalServer(raw.Raw, types.KindNode)
if err != nil {
@ -788,6 +798,7 @@ func (rc *ResourceCommand) Delete(ctx context.Context, client auth.ClientI) (err
types.KindClusterNetworkingConfig,
types.KindSessionRecordingConfig,
types.KindInstaller,
types.KindUIConfig,
}
if !slices.Contains(singletonResources, rc.ref.Kind) && (rc.ref.Kind == "" || rc.ref.Name == "") {
return trace.BadParameter("provide a full resource name to delete, for example:\n$ tctl rm cluster/east\n")
@ -995,6 +1006,12 @@ func (rc *ResourceCommand) Delete(ctx context.Context, client auth.ClientI) (err
return trace.NotFound("kubernetes server %q not found", rc.ref.Name)
}
fmt.Printf("kubernetes server %q has been deleted\n", rc.ref.Name)
case types.KindUIConfig:
err := client.DeleteUIConfig(ctx)
if err != nil {
return trace.Wrap(err)
}
fmt.Printf("%s has been deleted\n", types.KindUIConfig)
case types.KindInstaller:
err := client.DeleteInstaller(ctx, rc.ref.Name)
if err != nil {
@ -1545,6 +1562,15 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client auth.Client
return nil, trace.Wrap(err)
}
return &installerCollection{installers: []types.Installer{inst}}, nil
case types.KindUIConfig:
if rc.ref.Name != "" {
return nil, trace.BadParameter("only simple `tctl get %v` can be used", types.KindUIConfig)
}
uiconfig, err := client.GetUIConfig(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
return &uiConfigCollection{uiconfig}, nil
case types.KindDatabaseService:
resourceName := rc.ref.Name
listReq := proto.ListResourcesRequest{

View file

@ -44,6 +44,10 @@ const cfg = {
baseUrl: window.location.origin,
ui: {
scrollbackLines: 1000,
},
auth: {
localAuthEnabled: true,
allowPasswordless: false,
@ -554,6 +558,10 @@ const cfg = {
});
},
getUIConfig() {
return cfg.ui;
},
init(backendConfig = {}) {
mergeDeep(this, backendConfig);
},

View file

@ -20,6 +20,8 @@ import { debounce, isInteger } from 'shared/utils/highbar';
import { WebLinksAddon } from 'xterm-addon-web-links';
import Logger from 'shared/libs/logger';
import cfg from 'teleport/config';
import { TermEvent } from './enums';
import Tty from './tty';
@ -49,7 +51,9 @@ export default class TtyTerminal {
this._el = el;
this._fontFamily = fontFamily || undefined;
this._fontSize = fontSize || 14;
this._scrollBack = scrollBack;
// Passing scrollback will overwrite the default config. This is to support ttyplayer.
// Default to the config when not passed anything, which is the normal usecase
this._scrollBack = scrollBack || cfg.ui.scrollbackLines;
this.tty = tty;
this.term = null;
@ -63,7 +67,7 @@ export default class TtyTerminal {
lineHeight: 1,
fontFamily: this._fontFamily,
fontSize: this._fontSize,
scrollback: this._scrollBack || 1000,
scrollback: this._scrollBack,
cursorBlink: false,
allowTransparency: true,
});