diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index adc3df1e7..42100f514 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -5,6 +5,8 @@ import ( "path" "time" + "github.com/portainer/portainer/api/bolt/tunnelserver" + "github.com/boltdb/bolt" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/dockerhub" @@ -51,6 +53,7 @@ type Store struct { TeamMembershipService *teammembership.Service TeamService *team.Service TemplateService *template.Service + TunnelServerService *tunnelserver.Service UserService *user.Service VersionService *version.Service WebhookService *webhook.Service @@ -220,6 +223,12 @@ func (store *Store) initServices() error { } store.TemplateService = templateService + tunnelServerService, err := tunnelserver.NewService(store.db) + if err != nil { + return err + } + store.TunnelServerService = tunnelServerService + userService, err := user.NewService(store.db) if err != nil { return err diff --git a/api/bolt/endpoint/endpoint.go b/api/bolt/endpoint/endpoint.go index 53156d2a2..69d9dc4ac 100644 --- a/api/bolt/endpoint/endpoint.go +++ b/api/bolt/endpoint/endpoint.go @@ -63,7 +63,7 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) { cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var endpoint portainer.Endpoint - err := internal.UnmarshalObject(v, &endpoint) + err := internal.UnmarshalObjectWithJsoniter(v, &endpoint) if err != nil { return err } diff --git a/api/bolt/internal/json.go b/api/bolt/internal/json.go index 9f69f06ee..f0636a599 100644 --- a/api/bolt/internal/json.go +++ b/api/bolt/internal/json.go @@ -2,6 +2,8 @@ package internal import ( "encoding/json" + + jsoniter "github.com/json-iterator/go" ) // MarshalObject encodes an object to binary format @@ -13,3 +15,11 @@ func MarshalObject(object interface{}) ([]byte, error) { func UnmarshalObject(data []byte, object interface{}) error { return json.Unmarshal(data, object) } + +// UnmarshalObjectWithJsoniter decodes an object from binary data +// using the jsoniter library. It is mainly used to accelerate endpoint +// decoding at the moment. +func UnmarshalObjectWithJsoniter(data []byte, object interface{}) error { + var jsoni = jsoniter.ConfigCompatibleWithStandardLibrary + return jsoni.Unmarshal(data, &object) +} diff --git a/api/bolt/migrator/migrate_dbversion18.go b/api/bolt/migrator/migrate_dbversion18.go new file mode 100644 index 000000000..2de9dc2fd --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion18.go @@ -0,0 +1,16 @@ +package migrator + +import portainer "github.com/portainer/portainer/api" + +func (m *Migrator) updateSettingsToDBVersion19() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + if legacySettings.EdgeAgentCheckinInterval == 0 { + legacySettings.EdgeAgentCheckinInterval = portainer.DefaultEdgeAgentCheckinIntervalInSeconds + } + + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 570e8be5e..5210fe0b0 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -249,5 +249,13 @@ func (m *Migrator) Migrate() error { } } + // Portainer 1.21.1 + if m.currentDBVersion < 19 { + err := m.updateSettingsToDBVersion19() + if err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/bolt/tunnelserver/tunnelserver.go b/api/bolt/tunnelserver/tunnelserver.go new file mode 100644 index 000000000..52ba4c101 --- /dev/null +++ b/api/bolt/tunnelserver/tunnelserver.go @@ -0,0 +1,48 @@ +package tunnelserver + +import ( + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "tunnel_server" + infoKey = "INFO" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Info retrieve the TunnelServerInfo object. +func (service *Service) Info() (*portainer.TunnelServerInfo, error) { + var info portainer.TunnelServerInfo + + err := internal.GetObject(service.db, BucketName, []byte(infoKey), &info) + if err != nil { + return nil, err + } + + return &info, nil +} + +// UpdateInfo persists a TunnelServerInfo object. +func (service *Service) UpdateInfo(settings *portainer.TunnelServerInfo) error { + return internal.UpdateObject(service.db, BucketName, []byte(infoKey), settings) +} diff --git a/api/chisel/key.go b/api/chisel/key.go new file mode 100644 index 000000000..7629f208c --- /dev/null +++ b/api/chisel/key.go @@ -0,0 +1,24 @@ +package chisel + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" +) + +// GenerateEdgeKey will generate a key that can be used by an Edge agent to register with a Portainer instance. +// The key represents the following data in this particular format: +// portainer_instance_url|tunnel_server_addr|tunnel_server_fingerprint|endpoint_ID +// The key returned by this function is a base64 encoded version of the data. +func (service *Service) GenerateEdgeKey(url, host string, endpointIdentifier int) string { + keyInformation := []string{ + url, + fmt.Sprintf("%s:%s", host, service.serverPort), + service.serverFingerprint, + strconv.Itoa(endpointIdentifier), + } + + key := strings.Join(keyInformation, "|") + return base64.RawStdEncoding.EncodeToString([]byte(key)) +} diff --git a/api/chisel/schedules.go b/api/chisel/schedules.go new file mode 100644 index 000000000..39ba9a340 --- /dev/null +++ b/api/chisel/schedules.go @@ -0,0 +1,47 @@ +package chisel + +import ( + "strconv" + + portainer "github.com/portainer/portainer/api" +) + +// AddSchedule register a schedule inside the tunnel details associated to an endpoint. +func (service *Service) AddSchedule(endpointID portainer.EndpointID, schedule *portainer.EdgeSchedule) { + tunnel := service.GetTunnelDetails(endpointID) + + existingScheduleIndex := -1 + for idx, existingSchedule := range tunnel.Schedules { + if existingSchedule.ID == schedule.ID { + existingScheduleIndex = idx + break + } + } + + if existingScheduleIndex == -1 { + tunnel.Schedules = append(tunnel.Schedules, *schedule) + } else { + tunnel.Schedules[existingScheduleIndex] = *schedule + } + + key := strconv.Itoa(int(endpointID)) + service.tunnelDetailsMap.Set(key, tunnel) +} + +// RemoveSchedule will remove the specified schedule from each tunnel it was registered with. +func (service *Service) RemoveSchedule(scheduleID portainer.ScheduleID) { + for item := range service.tunnelDetailsMap.IterBuffered() { + tunnelDetails := item.Val.(*portainer.TunnelDetails) + + updatedSchedules := make([]portainer.EdgeSchedule, 0) + for _, schedule := range tunnelDetails.Schedules { + if schedule.ID == scheduleID { + continue + } + updatedSchedules = append(updatedSchedules, schedule) + } + + tunnelDetails.Schedules = updatedSchedules + service.tunnelDetailsMap.Set(item.Key, tunnelDetails) + } +} diff --git a/api/chisel/service.go b/api/chisel/service.go new file mode 100644 index 000000000..bb2d3357d --- /dev/null +++ b/api/chisel/service.go @@ -0,0 +1,191 @@ +package chisel + +import ( + "fmt" + "log" + "strconv" + "time" + + "github.com/dchest/uniuri" + + cmap "github.com/orcaman/concurrent-map" + + chserver "github.com/jpillora/chisel/server" + portainer "github.com/portainer/portainer/api" +) + +const ( + tunnelCleanupInterval = 10 * time.Second + requiredTimeout = 15 * time.Second + activeTimeout = 4*time.Minute + 30*time.Second +) + +// Service represents a service to manage the state of multiple reverse tunnels. +// It is used to start a reverse tunnel server and to manage the connection status of each tunnel +// connected to the tunnel server. +type Service struct { + serverFingerprint string + serverPort string + tunnelDetailsMap cmap.ConcurrentMap + endpointService portainer.EndpointService + tunnelServerService portainer.TunnelServerService + snapshotter portainer.Snapshotter + chiselServer *chserver.Server +} + +// NewService returns a pointer to a new instance of Service +func NewService(endpointService portainer.EndpointService, tunnelServerService portainer.TunnelServerService) *Service { + return &Service{ + tunnelDetailsMap: cmap.New(), + endpointService: endpointService, + tunnelServerService: tunnelServerService, + } +} + +// StartTunnelServer starts a tunnel server on the specified addr and port. +// It uses a seed to generate a new private/public key pair. If the seed cannot +// be found inside the database, it will generate a new one randomly and persist it. +// It starts the tunnel status verification process in the background. +// The snapshotter is used in the tunnel status verification process. +func (service *Service) StartTunnelServer(addr, port string, snapshotter portainer.Snapshotter) error { + keySeed, err := service.retrievePrivateKeySeed() + if err != nil { + return err + } + + config := &chserver.Config{ + Reverse: true, + KeySeed: keySeed, + } + + chiselServer, err := chserver.NewServer(config) + if err != nil { + return err + } + + service.serverFingerprint = chiselServer.GetFingerprint() + service.serverPort = port + + err = chiselServer.Start(addr, port) + if err != nil { + return err + } + service.chiselServer = chiselServer + + // TODO: work-around Chisel default behavior. + // By default, Chisel will allow anyone to connect if no user exists. + username, password := generateRandomCredentials() + err = service.chiselServer.AddUser(username, password, "127.0.0.1") + if err != nil { + return err + } + + service.snapshotter = snapshotter + go service.startTunnelVerificationLoop() + + return nil +} + +func (service *Service) retrievePrivateKeySeed() (string, error) { + var serverInfo *portainer.TunnelServerInfo + + serverInfo, err := service.tunnelServerService.Info() + if err == portainer.ErrObjectNotFound { + keySeed := uniuri.NewLen(16) + + serverInfo = &portainer.TunnelServerInfo{ + PrivateKeySeed: keySeed, + } + + err := service.tunnelServerService.UpdateInfo(serverInfo) + if err != nil { + return "", err + } + } else if err != nil { + return "", err + } + + return serverInfo.PrivateKeySeed, nil +} + +func (service *Service) startTunnelVerificationLoop() { + log.Printf("[DEBUG] [chisel, monitoring] [check_interval_seconds: %f] [message: starting tunnel management process]", tunnelCleanupInterval.Seconds()) + ticker := time.NewTicker(tunnelCleanupInterval) + stopSignal := make(chan struct{}) + + for { + select { + case <-ticker.C: + service.checkTunnels() + case <-stopSignal: + ticker.Stop() + return + } + } +} + +func (service *Service) checkTunnels() { + for item := range service.tunnelDetailsMap.IterBuffered() { + tunnel := item.Val.(*portainer.TunnelDetails) + + if tunnel.LastActivity.IsZero() || tunnel.Status == portainer.EdgeAgentIdle { + continue + } + + elapsed := time.Since(tunnel.LastActivity) + log.Printf("[DEBUG] [chisel,monitoring] [endpoint_id: %s] [status: %s] [status_time_seconds: %f] [message: endpoint tunnel monitoring]", item.Key, tunnel.Status, elapsed.Seconds()) + + if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed.Seconds() < requiredTimeout.Seconds() { + continue + } else if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed.Seconds() > requiredTimeout.Seconds() { + log.Printf("[DEBUG] [chisel,monitoring] [endpoint_id: %s] [status: %s] [status_time_seconds: %f] [timeout_seconds: %f] [message: REQUIRED state timeout exceeded]", item.Key, tunnel.Status, elapsed.Seconds(), requiredTimeout.Seconds()) + } + + if tunnel.Status == portainer.EdgeAgentActive && elapsed.Seconds() < activeTimeout.Seconds() { + continue + } else if tunnel.Status == portainer.EdgeAgentActive && elapsed.Seconds() > activeTimeout.Seconds() { + log.Printf("[DEBUG] [chisel,monitoring] [endpoint_id: %s] [status: %s] [status_time_seconds: %f] [timeout_seconds: %f] [message: ACTIVE state timeout exceeded]", item.Key, tunnel.Status, elapsed.Seconds(), activeTimeout.Seconds()) + + endpointID, err := strconv.Atoi(item.Key) + if err != nil { + log.Printf("[ERROR] [chisel,snapshot,conversion] Invalid endpoint identifier (id: %s): %s", item.Key, err) + } + + err = service.snapshotEnvironment(portainer.EndpointID(endpointID), tunnel.Port) + if err != nil { + log.Printf("[ERROR] [snapshot] Unable to snapshot Edge endpoint (id: %s): %s", item.Key, err) + } + } + + if len(tunnel.Schedules) > 0 { + endpointID, err := strconv.Atoi(item.Key) + if err != nil { + log.Printf("[ERROR] [chisel,conversion] Invalid endpoint identifier (id: %s): %s", item.Key, err) + continue + } + + service.SetTunnelStatusToIdle(portainer.EndpointID(endpointID)) + } else { + service.tunnelDetailsMap.Remove(item.Key) + } + + } +} + +func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error { + endpoint, err := service.endpointService.Endpoint(portainer.EndpointID(endpointID)) + if err != nil { + return err + } + + endpointURL := endpoint.URL + endpoint.URL = fmt.Sprintf("tcp://localhost:%d", tunnelPort) + snapshot, err := service.snapshotter.CreateSnapshot(endpoint) + if err != nil { + return err + } + + endpoint.Snapshots = []portainer.Snapshot{*snapshot} + endpoint.URL = endpointURL + return service.endpointService.UpdateEndpoint(endpoint.ID, endpoint) +} diff --git a/api/chisel/tunnel.go b/api/chisel/tunnel.go new file mode 100644 index 000000000..e0cba1caf --- /dev/null +++ b/api/chisel/tunnel.go @@ -0,0 +1,144 @@ +package chisel + +import ( + "encoding/base64" + "fmt" + "math/rand" + "strconv" + "strings" + "time" + + "github.com/portainer/libcrypto" + + "github.com/dchest/uniuri" + portainer "github.com/portainer/portainer/api" +) + +const ( + minAvailablePort = 49152 + maxAvailablePort = 65535 +) + +// getUnusedPort is used to generate an unused random port in the dynamic port range. +// Dynamic ports (also called private ports) are 49152 to 65535. +func (service *Service) getUnusedPort() int { + port := randomInt(minAvailablePort, maxAvailablePort) + + for item := range service.tunnelDetailsMap.IterBuffered() { + tunnel := item.Val.(*portainer.TunnelDetails) + if tunnel.Port == port { + return service.getUnusedPort() + } + } + + return port +} + +func randomInt(min, max int) int { + return min + rand.Intn(max-min) +} + +// GetTunnelDetails returns information about the tunnel associated to an endpoint. +func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) *portainer.TunnelDetails { + key := strconv.Itoa(int(endpointID)) + + if item, ok := service.tunnelDetailsMap.Get(key); ok { + tunnelDetails := item.(*portainer.TunnelDetails) + return tunnelDetails + } + + schedules := make([]portainer.EdgeSchedule, 0) + return &portainer.TunnelDetails{ + Status: portainer.EdgeAgentIdle, + Port: 0, + Schedules: schedules, + Credentials: "", + } +} + +// SetTunnelStatusToActive update the status of the tunnel associated to the specified endpoint. +// It sets the status to ACTIVE. +func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) { + tunnel := service.GetTunnelDetails(endpointID) + tunnel.Status = portainer.EdgeAgentActive + tunnel.Credentials = "" + tunnel.LastActivity = time.Now() + + key := strconv.Itoa(int(endpointID)) + service.tunnelDetailsMap.Set(key, tunnel) +} + +// SetTunnelStatusToIdle update the status of the tunnel associated to the specified endpoint. +// It sets the status to IDLE. +// It removes any existing credentials associated to the tunnel. +func (service *Service) SetTunnelStatusToIdle(endpointID portainer.EndpointID) { + tunnel := service.GetTunnelDetails(endpointID) + + tunnel.Status = portainer.EdgeAgentIdle + tunnel.Port = 0 + tunnel.LastActivity = time.Now() + + credentials := tunnel.Credentials + if credentials != "" { + tunnel.Credentials = "" + service.chiselServer.DeleteUser(strings.Split(credentials, ":")[0]) + } + + key := strconv.Itoa(int(endpointID)) + service.tunnelDetailsMap.Set(key, tunnel) +} + +// SetTunnelStatusToRequired update the status of the tunnel associated to the specified endpoint. +// It sets the status to REQUIRED. +// If no port is currently associated to the tunnel, it will associate a random unused port to the tunnel +// and generate temporary credentials that can be used to establish a reverse tunnel on that port. +// Credentials are encrypted using the Edge ID associated to the endpoint. +func (service *Service) SetTunnelStatusToRequired(endpointID portainer.EndpointID) error { + tunnel := service.GetTunnelDetails(endpointID) + + if tunnel.Port == 0 { + endpoint, err := service.endpointService.Endpoint(endpointID) + if err != nil { + return err + } + + tunnel.Status = portainer.EdgeAgentManagementRequired + tunnel.Port = service.getUnusedPort() + tunnel.LastActivity = time.Now() + + username, password := generateRandomCredentials() + authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tunnel.Port) + err = service.chiselServer.AddUser(username, password, authorizedRemote) + if err != nil { + return err + } + + credentials, err := encryptCredentials(username, password, endpoint.EdgeID) + if err != nil { + return err + } + tunnel.Credentials = credentials + + key := strconv.Itoa(int(endpointID)) + service.tunnelDetailsMap.Set(key, tunnel) + } + + return nil +} + +func generateRandomCredentials() (string, string) { + username := uniuri.NewLen(8) + password := uniuri.NewLen(8) + return username, password +} + +func encryptCredentials(username, password, key string) (string, error) { + credentials := fmt.Sprintf("%s:%s", username, password) + + encryptedCredentials, err := libcrypto.Encrypt([]byte(credentials), []byte(key)) + if err != nil { + return "", err + } + + return base64.RawStdEncoding.EncodeToString(encryptedCredentials), nil +} diff --git a/api/cli/cli.go b/api/cli/cli.go index 6eef1a432..ba1d4d829 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -33,6 +33,8 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { flags := &portainer.CLIFlags{ Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(), + TunnelAddr: kingpin.Flag("tunnel-addr", "Address to serve the tunnel server").Default(defaultTunnelServerAddress).String(), + TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(), Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(), Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(), EndpointURL: kingpin.Flag("host", "Endpoint URL").Short('H').String(), diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 1913e4915..504742771 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -3,21 +3,23 @@ package cli const ( - defaultBindAddress = ":9000" - defaultDataDirectory = "/data" - defaultAssetsDirectory = "./" - defaultNoAuth = "false" - defaultNoAnalytics = "false" - defaultTLS = "false" - defaultTLSSkipVerify = "false" - defaultTLSCACertPath = "/certs/ca.pem" - defaultTLSCertPath = "/certs/cert.pem" - defaultTLSKeyPath = "/certs/key.pem" - defaultSSL = "false" - defaultSSLCertPath = "/certs/portainer.crt" - defaultSSLKeyPath = "/certs/portainer.key" - defaultSyncInterval = "60s" - defaultSnapshot = "true" - defaultSnapshotInterval = "5m" - defaultTemplateFile = "/templates.json" + defaultBindAddress = ":9000" + defaultTunnelServerAddress = "0.0.0.0" + defaultTunnelServerPort = "8000" + defaultDataDirectory = "/data" + defaultAssetsDirectory = "./" + defaultNoAuth = "false" + defaultNoAnalytics = "false" + defaultTLS = "false" + defaultTLSSkipVerify = "false" + defaultTLSCACertPath = "/certs/ca.pem" + defaultTLSCertPath = "/certs/cert.pem" + defaultTLSKeyPath = "/certs/key.pem" + defaultSSL = "false" + defaultSSLCertPath = "/certs/portainer.crt" + defaultSSLKeyPath = "/certs/portainer.key" + defaultSyncInterval = "60s" + defaultSnapshot = "true" + defaultSnapshotInterval = "5m" + defaultTemplateFile = "/templates.json" ) diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index e2ee01795..4e7ce7c3e 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -1,21 +1,23 @@ package cli const ( - defaultBindAddress = ":9000" - defaultDataDirectory = "C:\\data" - defaultAssetsDirectory = "./" - defaultNoAuth = "false" - defaultNoAnalytics = "false" - defaultTLS = "false" - defaultTLSSkipVerify = "false" - defaultTLSCACertPath = "C:\\certs\\ca.pem" - defaultTLSCertPath = "C:\\certs\\cert.pem" - defaultTLSKeyPath = "C:\\certs\\key.pem" - defaultSSL = "false" - defaultSSLCertPath = "C:\\certs\\portainer.crt" - defaultSSLKeyPath = "C:\\certs\\portainer.key" - defaultSyncInterval = "60s" - defaultSnapshot = "true" - defaultSnapshotInterval = "5m" - defaultTemplateFile = "/templates.json" + defaultBindAddress = ":9000" + defaultTunnelServerAddress = "0.0.0.0" + defaultTunnelServerPort = "8000" + defaultDataDirectory = "C:\\data" + defaultAssetsDirectory = "./" + defaultNoAuth = "false" + defaultNoAnalytics = "false" + defaultTLS = "false" + defaultTLSSkipVerify = "false" + defaultTLSCACertPath = "C:\\certs\\ca.pem" + defaultTLSCertPath = "C:\\certs\\cert.pem" + defaultTLSKeyPath = "C:\\certs\\key.pem" + defaultSSL = "false" + defaultSSLCertPath = "C:\\certs\\portainer.crt" + defaultSSLKeyPath = "C:\\certs\\portainer.key" + defaultSyncInterval = "60s" + defaultSnapshot = "true" + defaultSnapshotInterval = "5m" + defaultTemplateFile = "/templates.json" ) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 8b34fc070..961039738 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -2,10 +2,13 @@ package main import ( "encoding/json" + "log" "os" "strings" "time" + "github.com/portainer/portainer/api/chisel" + "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt" "github.com/portainer/portainer/api/cli" @@ -20,8 +23,6 @@ import ( "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/ldap" "github.com/portainer/portainer/api/libcompose" - - "log" ) func initCLI() *portainer.CLIFlags { @@ -69,12 +70,12 @@ func initStore(dataStorePath string, fileService portainer.FileService) *bolt.St return store } -func initComposeStackManager(dataStorePath string) portainer.ComposeStackManager { - return libcompose.NewComposeStackManager(dataStorePath) +func initComposeStackManager(dataStorePath string, reverseTunnelService portainer.ReverseTunnelService) portainer.ComposeStackManager { + return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService) } -func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.SwarmStackManager, error) { - return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService) +func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (portainer.SwarmStackManager, error) { + return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService) } func initJWTService(authenticationEnabled bool) portainer.JWTService { @@ -104,8 +105,8 @@ func initGitService() portainer.GitService { return &git.Service{} } -func initClientFactory(signatureService portainer.DigitalSignatureService) *docker.ClientFactory { - return docker.NewClientFactory(signatureService) +func initClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *docker.ClientFactory { + return docker.NewClientFactory(signatureService, reverseTunnelService) } func initSnapshotter(clientFactory *docker.ClientFactory) portainer.Snapshotter { @@ -196,7 +197,7 @@ func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, schedul return scheduleService.CreateSchedule(endpointSyncSchedule) } -func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService portainer.JobService, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, fileService portainer.FileService) error { +func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService portainer.JobService, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) error { schedules, err := scheduleService.Schedules() if err != nil { return err @@ -213,6 +214,13 @@ func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService p return err } } + + if schedule.EdgeSchedule != nil { + for _, endpointID := range schedule.EdgeSchedule.Endpoints { + reverseTunnelService.AddSchedule(endpointID, schedule.EdgeSchedule) + } + } + } return nil @@ -265,6 +273,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL AllowPrivilegedModeForRegularUsers: true, EnableHostManagementFeatures: false, SnapshotInterval: *flags.SnapshotInterval, + EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, } if *flags.Templates != "" { @@ -540,7 +549,9 @@ func main() { log.Fatal(err) } - clientFactory := initClientFactory(digitalSignatureService) + reverseTunnelService := chisel.NewService(store.EndpointService, store.TunnelServerService) + + clientFactory := initClientFactory(digitalSignatureService, reverseTunnelService) jobService := initJobService(clientFactory) @@ -551,12 +562,12 @@ func main() { endpointManagement = false } - swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService) + swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService, reverseTunnelService) if err != nil { log.Fatal(err) } - composeStackManager := initComposeStackManager(*flags.Data) + composeStackManager := initComposeStackManager(*flags.Data, reverseTunnelService) err = initTemplates(store.TemplateService, fileService, *flags.Templates, *flags.TemplateFile) if err != nil { @@ -570,7 +581,7 @@ func main() { jobScheduler := initJobScheduler() - err = loadSchedulesFromDatabase(jobScheduler, jobService, store.ScheduleService, store.EndpointService, fileService) + err = loadSchedulesFromDatabase(jobScheduler, jobService, store.ScheduleService, store.EndpointService, fileService, reverseTunnelService) if err != nil { log.Fatal(err) } @@ -658,7 +669,13 @@ func main() { go terminateIfNoAdminCreated(store.UserService) } + err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotter) + if err != nil { + log.Fatal(err) + } + var server portainer.Server = &http.Server{ + ReverseTunnelService: reverseTunnelService, Status: applicationStatus, BindAddress: *flags.Addr, AssetsPath: *flags.Assets, diff --git a/api/cron/job_snapshot.go b/api/cron/job_snapshot.go index c7828b164..458d026c0 100644 --- a/api/cron/job_snapshot.go +++ b/api/cron/job_snapshot.go @@ -53,7 +53,7 @@ func (runner *SnapshotJobRunner) Run() { } for _, endpoint := range endpoints { - if endpoint.Type == portainer.AzureEnvironment { + if endpoint.Type == portainer.AzureEnvironment || endpoint.Type == portainer.EdgeAgentEnvironment { continue } diff --git a/api/crypto/ecdsa.go b/api/crypto/ecdsa.go index 35cc0b283..9148eb68d 100644 --- a/api/crypto/ecdsa.go +++ b/api/crypto/ecdsa.go @@ -8,6 +8,8 @@ import ( "encoding/base64" "encoding/hex" "math/big" + + "github.com/portainer/libcrypto" ) const ( @@ -111,7 +113,7 @@ func (service *ECDSAService) CreateSignature(message string) (string, error) { message = service.secret } - hash := HashFromBytes([]byte(message)) + hash := libcrypto.HashFromBytes([]byte(message)) r := big.NewInt(0) s := big.NewInt(0) diff --git a/api/crypto/crypto.go b/api/crypto/hash.go similarity index 100% rename from api/crypto/crypto.go rename to api/crypto/hash.go diff --git a/api/crypto/md5.go b/api/crypto/md5.go deleted file mode 100644 index 42ca24602..000000000 --- a/api/crypto/md5.go +++ /dev/null @@ -1,10 +0,0 @@ -package crypto - -import "crypto/md5" - -// HashFromBytes returns the hash of the specified data -func HashFromBytes(data []byte) []byte { - digest := md5.New() - digest.Write(data) - return digest.Sum(nil) -} diff --git a/api/docker/client.go b/api/docker/client.go index af68781b2..b093f23cf 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -1,6 +1,7 @@ package docker import ( + "fmt" "net/http" "strings" "time" @@ -17,13 +18,15 @@ const ( // ClientFactory is used to create Docker clients type ClientFactory struct { - signatureService portainer.DigitalSignatureService + signatureService portainer.DigitalSignatureService + reverseTunnelService portainer.ReverseTunnelService } // NewClientFactory returns a new instance of a ClientFactory -func NewClientFactory(signatureService portainer.DigitalSignatureService) *ClientFactory { +func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *ClientFactory { return &ClientFactory{ - signatureService: signatureService, + signatureService: signatureService, + reverseTunnelService: reverseTunnelService, } } @@ -35,6 +38,8 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam return nil, unsupportedEnvironmentType } else if endpoint.Type == portainer.AgentOnDockerEnvironment { return createAgentClient(endpoint, factory.signatureService, nodeName) + } else if endpoint.Type == portainer.EdgeAgentEnvironment { + return createEdgeClient(endpoint, factory.reverseTunnelService, nodeName) } if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { @@ -63,6 +68,28 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) { ) } +func createEdgeClient(endpoint *portainer.Endpoint, reverseTunnelService portainer.ReverseTunnelService, nodeName string) (*client.Client, error) { + httpCli, err := httpClient(endpoint) + if err != nil { + return nil, err + } + + headers := map[string]string{} + if nodeName != "" { + headers[portainer.PortainerAgentTargetHeader] = nodeName + } + + tunnel := reverseTunnelService.GetTunnelDetails(endpoint.ID) + endpointURL := fmt.Sprintf("http://localhost:%d", tunnel.Port) + + return client.NewClientWithOpts( + client.WithHost(endpointURL), + client.WithVersion(portainer.SupportedDockerAPIVersion), + client.WithHTTPClient(httpCli), + client.WithHTTPHeaders(headers), + ) +} + func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) { httpCli, err := httpClient(endpoint) if err != nil { diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index 3f01bbb26..e50eacb63 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -3,6 +3,7 @@ package exec import ( "bytes" "encoding/json" + "fmt" "os" "os/exec" "path" @@ -13,20 +14,22 @@ import ( // SwarmStackManager represents a service for managing stacks. type SwarmStackManager struct { - binaryPath string - dataPath string - signatureService portainer.DigitalSignatureService - fileService portainer.FileService + binaryPath string + dataPath string + signatureService portainer.DigitalSignatureService + fileService portainer.FileService + reverseTunnelService portainer.ReverseTunnelService } // NewSwarmStackManager initializes a new SwarmStackManager service. // It also updates the configuration of the Docker CLI binary. -func NewSwarmStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*SwarmStackManager, error) { +func NewSwarmStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (*SwarmStackManager, error) { manager := &SwarmStackManager{ - binaryPath: binaryPath, - dataPath: dataPath, - signatureService: signatureService, - fileService: fileService, + binaryPath: binaryPath, + dataPath: dataPath, + signatureService: signatureService, + fileService: fileService, + reverseTunnelService: reverseTunnelService, } err := manager.updateDockerCLIConfiguration(dataPath) @@ -39,7 +42,7 @@ func NewSwarmStackManager(binaryPath, dataPath string, signatureService portaine // Login executes the docker login command against a list of registries (including DockerHub). func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) { - command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) + command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) for _, registry := range registries { if registry.Authentication { registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL) @@ -55,7 +58,7 @@ func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registri // Logout executes the docker logout command. func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error { - command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) + command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) args = append(args, "logout") return runCommandAndCaptureStdErr(command, args, nil, "") } @@ -63,7 +66,7 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error { // Deploy executes the docker stack deploy command. func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error { stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) - command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) + command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) if prune { args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) @@ -82,7 +85,7 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, end // Remove executes the docker stack rm command. func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { - command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) + command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) args = append(args, "stack", "rm", stack.Name) return runCommandAndCaptureStdErr(command, args, nil, "") } @@ -106,7 +109,7 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor return nil } -func prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portainer.Endpoint) (string, []string) { +func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portainer.Endpoint) (string, []string) { // Assume Linux as a default command := path.Join(binaryPath, "docker") @@ -116,7 +119,14 @@ func prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portaine args := make([]string, 0) args = append(args, "--config", dataPath) - args = append(args, "-H", endpoint.URL) + + endpointURL := endpoint.URL + if endpoint.Type == portainer.EdgeAgentEnvironment { + tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID) + endpointURL = fmt.Sprintf("tcp://localhost:%d", tunnel.Port) + } + + args = append(args, "-H", endpointURL) if endpoint.TLSConfig.TLS { args = append(args, "--tls") diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go index 15a1101ba..69db8f54d 100644 --- a/api/http/handler/endpointproxy/handler.go +++ b/api/http/handler/endpointproxy/handler.go @@ -11,9 +11,11 @@ import ( // Handler is the HTTP handler used to proxy requests to external APIs. type Handler struct { *mux.Router - requestBouncer *security.RequestBouncer - EndpointService portainer.EndpointService - ProxyManager *proxy.Manager + requestBouncer *security.RequestBouncer + EndpointService portainer.EndpointService + SettingsService portainer.SettingsService + ProxyManager *proxy.Manager + ReverseTunnelService portainer.ReverseTunnelService } // NewHandler creates a handler to proxy requests to external APIs. diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go index a756a4fb7..782f81a25 100644 --- a/api/http/handler/endpointproxy/proxy_azure.go +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -29,7 +29,7 @@ func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.R } var proxy http.Handler - proxy = handler.ProxyManager.GetProxy(string(endpointID)) + proxy = handler.ProxyManager.GetProxy(endpoint) if proxy == nil { proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) if err != nil { diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index b1b911452..bdd5c8322 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -3,6 +3,7 @@ package endpointproxy import ( "errors" "strconv" + "time" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -24,7 +25,7 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - if endpoint.Status == portainer.EndpointStatusDown { + if endpoint.Type != portainer.EdgeAgentEnvironment && endpoint.Status == portainer.EndpointStatusDown { return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to query endpoint", errors.New("Endpoint is down")} } @@ -33,8 +34,32 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } + if endpoint.Type == portainer.EdgeAgentEnvironment { + if endpoint.EdgeID == "" { + return &httperror.HandlerError{http.StatusInternalServerError, "No Edge agent registered with the endpoint", errors.New("No agent available")} + } + + tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID) + if tunnel.Status == portainer.EdgeAgentIdle { + handler.ProxyManager.DeleteProxy(endpoint) + + err := handler.ReverseTunnelService.SetTunnelStatusToRequired(endpoint.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update tunnel status", err} + } + + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + waitForAgentToConnect := time.Duration(settings.EdgeAgentCheckinInterval) * time.Second + time.Sleep(waitForAgentToConnect * 2) + } + } + var proxy http.Handler - proxy = handler.ProxyManager.GetProxy(string(endpointID)) + proxy = handler.ProxyManager.GetProxy(endpoint) if proxy == nil { proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) if err != nil { diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 40348899f..6f4163025 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -1,8 +1,11 @@ package endpoints import ( + "errors" "log" + "net" "net/http" + "net/url" "runtime" "strconv" @@ -41,7 +44,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { endpointType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointType", false) if err != nil || endpointType == 0 { - return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 3 (Azure environment)") + return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge Agent environment)") } payload.EndpointType = endpointType @@ -149,6 +152,8 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { if portainer.EndpointType(payload.EndpointType) == portainer.AzureEnvironment { return handler.createAzureEndpoint(payload) + } else if portainer.EndpointType(payload.EndpointType) == portainer.EdgeAgentEnvironment { + return handler.createEdgeAgentEndpoint(payload) } if payload.TLS { @@ -195,6 +200,52 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po return endpoint, nil } +func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + endpointType := portainer.EdgeAgentEnvironment + endpointID := handler.EndpointService.GetNextIdentifier() + + portainerURL, err := url.Parse(payload.URL) + if err != nil { + return nil, &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint URL", err} + } + + portainerHost, _, err := net.SplitHostPort(portainerURL.Host) + if err != nil { + portainerHost = portainerURL.Host + } + + if portainerHost == "localhost" { + return nil, &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint URL", errors.New("cannot use localhost as endpoint URL")} + } + + edgeKey := handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, endpointID) + + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: payload.Name, + URL: portainerHost, + Type: endpointType, + GroupID: portainer.EndpointGroupID(payload.GroupID), + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + Tags: payload.Tags, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.Snapshot{}, + EdgeKey: edgeKey, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + } + + return endpoint, nil +} + func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { endpointType := portainer.DockerEnvironment diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 0dd25963c..ca7af3aa9 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -41,7 +41,7 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint from the database", err} } - handler.ProxyManager.DeleteProxy(string(endpointID)) + handler.ProxyManager.DeleteProxy(endpoint) return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_status_inspect.go b/api/http/handler/endpoints/endpoint_status_inspect.go new file mode 100644 index 000000000..da34a3bfe --- /dev/null +++ b/api/http/handler/endpoints/endpoint_status_inspect.go @@ -0,0 +1,77 @@ +package endpoints + +import ( + "errors" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +type endpointStatusInspectResponse struct { + Status string `json:"status"` + Port int `json:"port"` + Schedules []portainer.EdgeSchedule `json:"schedules"` + CheckinInterval int `json:"checkin"` + Credentials string `json:"credentials"` +} + +// GET request on /api/endpoints/:id/status +func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if endpoint.Type != portainer.EdgeAgentEnvironment { + return &httperror.HandlerError{http.StatusInternalServerError, "Status unavailable for non Edge agent endpoints", errors.New("Status unavailable")} + } + + edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) + if edgeIdentifier == "" { + return &httperror.HandlerError{http.StatusForbidden, "Missing Edge identifier", errors.New("missing Edge identifier")} + } + + if endpoint.EdgeID != "" && endpoint.EdgeID != edgeIdentifier { + return &httperror.HandlerError{http.StatusForbidden, "Invalid Edge identifier", errors.New("invalid Edge identifier")} + } + + if endpoint.EdgeID == "" { + endpoint.EdgeID = edgeIdentifier + + err := handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to Unable to persist endpoint changes inside the database", err} + } + } + + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID) + + statusResponse := endpointStatusInspectResponse{ + Status: tunnel.Status, + Port: tunnel.Port, + Schedules: tunnel.Schedules, + CheckinInterval: settings.EdgeAgentCheckinInterval, + Credentials: tunnel.Credentials, + } + + if tunnel.Status == portainer.EdgeAgentManagementRequired { + handler.ReverseTunnelService.SetTunnelStatusToActive(endpoint.ID) + } + + return response.JSON(w, statusResponse) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 9896bd92a..17d0012bb 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -35,6 +35,8 @@ type Handler struct { ProxyManager *proxy.Manager Snapshotter portainer.Snapshotter JobService portainer.JobService + ReverseTunnelService portainer.ReverseTunnelService + SettingsService portainer.SettingsService } // NewHandler creates a handler to manage endpoint operations. @@ -65,5 +67,8 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/snapshot", bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/status", + bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet) + return h } diff --git a/api/http/handler/motd/motd.go b/api/http/handler/motd/motd.go index ba768becb..08ee020e8 100644 --- a/api/http/handler/motd/motd.go +++ b/api/http/handler/motd/motd.go @@ -5,9 +5,9 @@ import ( "net/http" "strings" + "github.com/portainer/libcrypto" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/http/client" ) @@ -42,7 +42,7 @@ func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) { message := strings.Join(data.Message, "\n") - hash := crypto.HashFromBytes([]byte(message)) + hash := libcrypto.HashFromBytes([]byte(message)) resp := motdResponse{ Title: data.Title, Message: message, diff --git a/api/http/handler/schedules/handler.go b/api/http/handler/schedules/handler.go index 5a160cd3b..303178c25 100644 --- a/api/http/handler/schedules/handler.go +++ b/api/http/handler/schedules/handler.go @@ -12,12 +12,13 @@ import ( // Handler is the HTTP handler used to handle schedule operations. type Handler struct { *mux.Router - ScheduleService portainer.ScheduleService - EndpointService portainer.EndpointService - SettingsService portainer.SettingsService - FileService portainer.FileService - JobService portainer.JobService - JobScheduler portainer.JobScheduler + ScheduleService portainer.ScheduleService + EndpointService portainer.EndpointService + SettingsService portainer.SettingsService + FileService portainer.FileService + JobService portainer.JobService + JobScheduler portainer.JobScheduler + ReverseTunnelService portainer.ReverseTunnelService } // NewHandler creates a handler to manage schedule operations. diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go index f4302bd4b..196913a33 100644 --- a/api/http/handler/schedules/schedule_create.go +++ b/api/http/handler/schedules/schedule_create.go @@ -1,9 +1,11 @@ package schedules import ( + "encoding/base64" "errors" "net/http" "strconv" + "strings" "time" "github.com/asaskevich/govalidator" @@ -113,7 +115,7 @@ func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) e return nil } -// POST /api/schedules?method=file/string +// POST /api/schedules?method=file|string func (handler *Handler) scheduleCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { settings, err := handler.SettingsService.Settings() if err != nil { @@ -219,6 +221,46 @@ func (handler *Handler) createScheduleObjectFromFileContentPayload(payload *sche } func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file []byte) error { + nonEdgeEndpointIDs := make([]portainer.EndpointID, 0) + edgeEndpointIDs := make([]portainer.EndpointID, 0) + + edgeCronExpression := strings.Split(schedule.CronExpression, " ") + if len(edgeCronExpression) == 6 { + edgeCronExpression = edgeCronExpression[1:] + } + + for _, ID := range schedule.ScriptExecutionJob.Endpoints { + + endpoint, err := handler.EndpointService.Endpoint(ID) + if err != nil { + return err + } + + if endpoint.Type != portainer.EdgeAgentEnvironment { + nonEdgeEndpointIDs = append(nonEdgeEndpointIDs, endpoint.ID) + } else { + edgeEndpointIDs = append(edgeEndpointIDs, endpoint.ID) + } + } + + if len(edgeEndpointIDs) > 0 { + edgeSchedule := &portainer.EdgeSchedule{ + ID: schedule.ID, + CronExpression: strings.Join(edgeCronExpression, " "), + Script: base64.RawStdEncoding.EncodeToString(file), + Endpoints: edgeEndpointIDs, + Version: 1, + } + + for _, endpointID := range edgeEndpointIDs { + handler.ReverseTunnelService.AddSchedule(endpointID, edgeSchedule) + } + + schedule.EdgeSchedule = edgeSchedule + } + + schedule.ScriptExecutionJob.Endpoints = nonEdgeEndpointIDs + scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(schedule.ID)), file) if err != nil { return err diff --git a/api/http/handler/schedules/schedule_delete.go b/api/http/handler/schedules/schedule_delete.go index a2ef81393..c30b01696 100644 --- a/api/http/handler/schedules/schedule_delete.go +++ b/api/http/handler/schedules/schedule_delete.go @@ -42,6 +42,8 @@ func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err} } + handler.ReverseTunnelService.RemoveSchedule(schedule.ID) + handler.JobScheduler.UnscheduleJob(schedule.ID) err = handler.ScheduleService.DeleteSchedule(portainer.ScheduleID(scheduleID)) diff --git a/api/http/handler/schedules/schedule_tasks.go b/api/http/handler/schedules/schedule_tasks.go index 1c2810e45..a4993e6cd 100644 --- a/api/http/handler/schedules/schedule_tasks.go +++ b/api/http/handler/schedules/schedule_tasks.go @@ -3,6 +3,7 @@ package schedules import ( "encoding/json" "errors" + "fmt" "net/http" "strconv" @@ -18,6 +19,7 @@ type taskContainer struct { Status string `json:"Status"` Created float64 `json:"Created"` Labels map[string]string `json:"Labels"` + Edge bool `json:"Edge"` } // GET request on /api/schedules/:id/tasks @@ -64,6 +66,22 @@ func (handler *Handler) scheduleTasks(w http.ResponseWriter, r *http.Request) *h tasks = append(tasks, endpointTasks...) } + if schedule.EdgeSchedule != nil { + for _, endpointID := range schedule.EdgeSchedule.Endpoints { + + cronTask := taskContainer{ + ID: fmt.Sprintf("schedule_%d", schedule.EdgeSchedule.ID), + EndpointID: endpointID, + Edge: true, + Status: "", + Created: 0, + Labels: map[string]string{}, + } + + tasks = append(tasks, cronTask) + } + } + return response.JSON(w, tasks) } @@ -87,6 +105,7 @@ func extractTasksFromContainerSnasphot(endpoint *portainer.Endpoint, scheduleID for _, container := range containers { if container.Labels["io.portainer.schedule.id"] == strconv.Itoa(int(scheduleID)) { container.EndpointID = endpoint.ID + container.Edge = false endpointTasks = append(endpointTasks, container) } } diff --git a/api/http/handler/schedules/schedule_update.go b/api/http/handler/schedules/schedule_update.go index 29a6dccbe..f68e77126 100644 --- a/api/http/handler/schedules/schedule_update.go +++ b/api/http/handler/schedules/schedule_update.go @@ -1,6 +1,7 @@ package schedules import ( + "encoding/base64" "errors" "net/http" "strconv" @@ -58,7 +59,15 @@ func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err} } - updateJobSchedule := updateSchedule(schedule, &payload) + updateJobSchedule := false + if schedule.EdgeSchedule != nil { + err := handler.updateEdgeSchedule(schedule, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update Edge schedule", err} + } + } else { + updateJobSchedule = updateSchedule(schedule, &payload) + } if payload.FileContent != nil { _, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(scheduleID), []byte(*payload.FileContent)) @@ -85,6 +94,46 @@ func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) * return response.JSON(w, schedule) } +func (handler *Handler) updateEdgeSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) error { + if payload.Name != nil { + schedule.Name = *payload.Name + } + + if payload.Endpoints != nil { + + edgeEndpointIDs := make([]portainer.EndpointID, 0) + + for _, ID := range payload.Endpoints { + endpoint, err := handler.EndpointService.Endpoint(ID) + if err != nil { + return err + } + + if endpoint.Type == portainer.EdgeAgentEnvironment { + edgeEndpointIDs = append(edgeEndpointIDs, endpoint.ID) + } + } + + schedule.EdgeSchedule.Endpoints = edgeEndpointIDs + } + + if payload.CronExpression != nil { + schedule.EdgeSchedule.CronExpression = *payload.CronExpression + schedule.EdgeSchedule.Version++ + } + + if payload.FileContent != nil { + schedule.EdgeSchedule.Script = base64.RawStdEncoding.EncodeToString([]byte(*payload.FileContent)) + schedule.EdgeSchedule.Version++ + } + + for _, endpointID := range schedule.EdgeSchedule.Endpoints { + handler.ReverseTunnelService.AddSchedule(endpointID, schedule.EdgeSchedule) + } + + return nil +} + func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) bool { updateJobSchedule := false diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index cc9c901dc..6de707c38 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -22,6 +22,7 @@ type settingsUpdatePayload struct { EnableHostManagementFeatures *bool SnapshotInterval *string TemplatesURL *string + EdgeAgentCheckinInterval *int } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { @@ -103,6 +104,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * } } + if payload.EdgeAgentCheckinInterval != nil { + settings.EdgeAgentCheckinInterval = *payload.EdgeAgentCheckinInterval + } + tlsError := handler.updateTLS(settings) if tlsError != nil { return tlsError diff --git a/api/http/handler/websocket/attach.go b/api/http/handler/websocket/attach.go index 95702179c..5de214eca 100644 --- a/api/http/handler/websocket/attach.go +++ b/api/http/handler/websocket/attach.go @@ -62,8 +62,10 @@ func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Reque r.Header.Del("Origin") - if params.nodeName != "" || params.endpoint.Type == portainer.AgentOnDockerEnvironment { - return handler.proxyWebsocketRequest(w, r, params) + if params.endpoint.Type == portainer.AgentOnDockerEnvironment { + return handler.proxyAgentWebsocketRequest(w, r, params) + } else if params.endpoint.Type == portainer.EdgeAgentEnvironment { + return handler.proxyEdgeAgentWebsocketRequest(w, r, params) } websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) diff --git a/api/http/handler/websocket/exec.go b/api/http/handler/websocket/exec.go index 3b3fad969..afe670a56 100644 --- a/api/http/handler/websocket/exec.go +++ b/api/http/handler/websocket/exec.go @@ -68,8 +68,10 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { r.Header.Del("Origin") - if params.nodeName != "" || params.endpoint.Type == portainer.AgentOnDockerEnvironment { - return handler.proxyWebsocketRequest(w, r, params) + if params.endpoint.Type == portainer.AgentOnDockerEnvironment { + return handler.proxyAgentWebsocketRequest(w, r, params) + } else if params.endpoint.Type == portainer.EdgeAgentEnvironment { + return handler.proxyEdgeAgentWebsocketRequest(w, r, params) } websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go index 853dea038..79dc0502a 100644 --- a/api/http/handler/websocket/handler.go +++ b/api/http/handler/websocket/handler.go @@ -11,10 +11,11 @@ import ( // Handler is the HTTP handler used to handle websocket operations. type Handler struct { *mux.Router - EndpointService portainer.EndpointService - SignatureService portainer.DigitalSignatureService - requestBouncer *security.RequestBouncer - connectionUpgrader websocket.Upgrader + EndpointService portainer.EndpointService + SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService + requestBouncer *security.RequestBouncer + connectionUpgrader websocket.Upgrader } // NewHandler creates a handler to manage websocket operations. diff --git a/api/http/handler/websocket/proxy.go b/api/http/handler/websocket/proxy.go index 7eba78b3a..4acb7a620 100644 --- a/api/http/handler/websocket/proxy.go +++ b/api/http/handler/websocket/proxy.go @@ -2,14 +2,37 @@ package websocket import ( "crypto/tls" + "fmt" + "net/http" + "net/url" + "github.com/gorilla/websocket" "github.com/koding/websocketproxy" "github.com/portainer/portainer/api" - "net/http" - "net/url" ) -func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { +func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { + tunnel := handler.ReverseTunnelService.GetTunnelDetails(params.endpoint.ID) + + endpointURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", tunnel.Port)) + if err != nil { + return err + } + + endpointURL.Scheme = "ws" + proxy := websocketproxy.NewProxy(endpointURL) + + proxy.Director = func(incoming *http.Request, out http.Header) { + out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) + } + + handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID) + proxy.ServeHTTP(w, r) + + return nil +} + +func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { agentURL, err := url.Parse(params.endpoint.URL) if err != nil { return err diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 873b099cf..a3e3d99f8 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -24,7 +24,9 @@ type ( DockerHubService portainer.DockerHubService SettingsService portainer.SettingsService SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService endpointIdentifier portainer.EndpointID + endpointType portainer.EndpointType } restrictedDockerOperationContext struct { isAdmin bool @@ -58,7 +60,19 @@ func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error } func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Response, error) { - return p.dockerTransport.RoundTrip(request) + response, err := p.dockerTransport.RoundTrip(request) + + if p.endpointType != portainer.EdgeAgentEnvironment { + return response, err + } + + if err == nil { + p.ReverseTunnelService.SetTunnelStatusToActive(p.endpointIdentifier) + } else { + p.ReverseTunnelService.SetTunnelStatusToIdle(p.endpointIdentifier) + } + + return response, err } func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) { diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 7308b7f34..0774e7f14 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -21,6 +21,7 @@ type proxyFactory struct { RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService } func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { @@ -29,21 +30,21 @@ func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { } func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) { - url, err := url.Parse(AzureAPIBaseURL) + remoteURL, err := url.Parse(AzureAPIBaseURL) if err != nil { return nil, err } - proxy := newSingleHostReverseProxyWithHostHeader(url) + proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) proxy.Transport = NewAzureTransport(credentials) return proxy, nil } -func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool, endpointID portainer.EndpointID) (http.Handler, error) { +func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, endpoint *portainer.Endpoint) (http.Handler, error) { u.Scheme = "https" - proxy := factory.createDockerReverseProxy(u, enableSignature, endpointID) + proxy := factory.createDockerReverseProxy(u, endpoint) config, err := crypto.CreateTLSConfigurationFromDisk(tlsConfig.TLSCACertPath, tlsConfig.TLSCertPath, tlsConfig.TLSKeyPath, tlsConfig.TLSSkipVerify) if err != nil { return nil, err @@ -53,13 +54,19 @@ func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portaine return proxy, nil } -func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, enableSignature bool, endpointID portainer.EndpointID) http.Handler { +func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, endpoint *portainer.Endpoint) http.Handler { u.Scheme = "http" - return factory.createDockerReverseProxy(u, enableSignature, endpointID) + return factory.createDockerReverseProxy(u, endpoint) } -func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignature bool, endpointID portainer.EndpointID) *httputil.ReverseProxy { +func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, endpoint *portainer.Endpoint) *httputil.ReverseProxy { proxy := newSingleHostReverseProxyWithHostHeader(u) + + enableSignature := false + if endpoint.Type == portainer.AgentOnDockerEnvironment { + enableSignature = true + } + transport := &proxyTransport{ enableSignature: enableSignature, ResourceControlService: factory.ResourceControlService, @@ -67,8 +74,10 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignatur SettingsService: factory.SettingsService, RegistryService: factory.RegistryService, DockerHubService: factory.DockerHubService, + ReverseTunnelService: factory.ReverseTunnelService, dockerTransport: &http.Transport{}, - endpointIdentifier: endpointID, + endpointIdentifier: endpoint.ID, + endpointType: endpoint.Type, } if enableSignature { diff --git a/api/http/proxy/factory_local.go b/api/http/proxy/factory_local.go index 431fd5604..1e461ab1a 100644 --- a/api/http/proxy/factory_local.go +++ b/api/http/proxy/factory_local.go @@ -8,7 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" ) -func (factory *proxyFactory) newLocalProxy(path string, endpointID portainer.EndpointID) http.Handler { +func (factory *proxyFactory) newLocalProxy(path string, endpoint *portainer.Endpoint) http.Handler { proxy := &localProxy{} transport := &proxyTransport{ enableSignature: false, @@ -18,7 +18,9 @@ func (factory *proxyFactory) newLocalProxy(path string, endpointID portainer.End RegistryService: factory.RegistryService, DockerHubService: factory.DockerHubService, dockerTransport: newSocketTransport(path), - endpointIdentifier: endpointID, + ReverseTunnelService: factory.ReverseTunnelService, + endpointIdentifier: endpoint.ID, + endpointType: endpoint.Type, } proxy.Transport = transport return proxy diff --git a/api/http/proxy/factory_local_windows.go b/api/http/proxy/factory_local_windows.go index a2105f886..01b020cf8 100644 --- a/api/http/proxy/factory_local_windows.go +++ b/api/http/proxy/factory_local_windows.go @@ -5,12 +5,11 @@ package proxy import ( "net" "net/http" - "github.com/Microsoft/go-winio" - + portainer "github.com/portainer/portainer/api" ) -func (factory *proxyFactory) newLocalProxy(path string, endpointID portainer.EndpointID) http.Handler { +func (factory *proxyFactory) newLocalProxy(path string, endpoint *portainer.Endpoint) http.Handler { proxy := &localProxy{} transport := &proxyTransport{ enableSignature: false, @@ -19,8 +18,10 @@ func (factory *proxyFactory) newLocalProxy(path string, endpointID portainer.End SettingsService: factory.SettingsService, RegistryService: factory.RegistryService, DockerHubService: factory.DockerHubService, + ReverseTunnelService: factory.ReverseTunnelService, dockerTransport: newNamedPipeTransport(path), - endpointIdentifier: endpointID, + endpointIdentifier: endpoint.ID, + endpointType: endpoint.Type, } proxy.Transport = transport return proxy diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 67da091a6..6d0311cfa 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -1,6 +1,7 @@ package proxy import ( + "fmt" "net/http" "net/url" "strconv" @@ -21,6 +22,7 @@ type ( // Manager represents a service used to manage Docker proxies. Manager struct { proxyFactory *proxyFactory + reverseTunnelService portainer.ReverseTunnelService proxies cmap.ConcurrentMap extensionProxies cmap.ConcurrentMap legacyExtensionProxies cmap.ConcurrentMap @@ -34,6 +36,7 @@ type ( RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService } ) @@ -50,13 +53,15 @@ func NewManager(parameters *ManagerParams) *Manager { RegistryService: parameters.RegistryService, DockerHubService: parameters.DockerHubService, SignatureService: parameters.SignatureService, + ReverseTunnelService: parameters.ReverseTunnelService, }, + reverseTunnelService: parameters.ReverseTunnelService, } } // GetProxy returns the proxy associated to a key -func (manager *Manager) GetProxy(key string) http.Handler { - proxy, ok := manager.proxies.Get(key) +func (manager *Manager) GetProxy(endpoint *portainer.Endpoint) http.Handler { + proxy, ok := manager.proxies.Get(string(endpoint.ID)) if !ok { return nil } @@ -76,8 +81,8 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht } // DeleteProxy deletes the proxy associated to a key -func (manager *Manager) DeleteProxy(key string) { - manager.proxies.Remove(key) +func (manager *Manager) DeleteProxy(endpoint *portainer.Endpoint) { + manager.proxies.Remove(string(endpoint.ID)) } // GetExtensionProxy returns an extension proxy associated to an extension identifier @@ -136,28 +141,40 @@ func (manager *Manager) CreateLegacyExtensionProxy(key, extensionAPIURL string) return proxy, nil } -func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *portainer.TLSConfiguration, endpointID portainer.EndpointID) (http.Handler, error) { - if endpointURL.Scheme == "tcp" { - if tlsConfig.TLS || tlsConfig.TLSSkipVerify { - return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, tlsConfig, false, endpointID) - } - return manager.proxyFactory.newDockerHTTPProxy(endpointURL, false, endpointID), nil +func (manager *Manager) createDockerProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + baseURL := endpoint.URL + if endpoint.Type == portainer.EdgeAgentEnvironment { + tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID) + baseURL = fmt.Sprintf("http://localhost:%d", tunnel.Port) } - return manager.proxyFactory.newLocalProxy(endpointURL.Path, endpointID), nil -} -func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - endpointURL, err := url.Parse(endpoint.URL) + endpointURL, err := url.Parse(baseURL) if err != nil { return nil, err } switch endpoint.Type { case portainer.AgentOnDockerEnvironment: - return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, true, endpoint.ID) - case portainer.AzureEnvironment: - return newAzureProxy(&endpoint.AzureCredentials) - default: - return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig, endpoint.ID) + return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, endpoint) + case portainer.EdgeAgentEnvironment: + return manager.proxyFactory.newDockerHTTPProxy(endpointURL, endpoint), nil } + + if endpointURL.Scheme == "tcp" { + if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { + return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, endpoint) + } + + return manager.proxyFactory.newDockerHTTPProxy(endpointURL, endpoint), nil + } + + return manager.proxyFactory.newLocalProxy(endpointURL.Path, endpoint), nil +} + +func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + if endpoint.Type == portainer.AzureEnvironment { + return newAzureProxy(&endpoint.AzureCredentials) + } + + return manager.createDockerProxy(endpoint) } diff --git a/api/http/server.go b/api/http/server.go index b232adcdc..f058d2e00 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -44,6 +44,7 @@ type Server struct { AuthDisabled bool EndpointManagement bool Status *portainer.Status + ReverseTunnelService portainer.ReverseTunnelService ExtensionManager portainer.ExtensionManager ComposeStackManager portainer.ComposeStackManager CryptoService portainer.CryptoService @@ -88,6 +89,7 @@ func (server *Server) Start() error { RegistryService: server.RegistryService, DockerHubService: server.DockerHubService, SignatureService: server.SignatureService, + ReverseTunnelService: server.ReverseTunnelService, } proxyManager := proxy.NewManager(proxyManagerParameters) @@ -132,6 +134,8 @@ func (server *Server) Start() error { endpointHandler.ProxyManager = proxyManager endpointHandler.Snapshotter = server.Snapshotter endpointHandler.JobService = server.JobService + endpointHandler.ReverseTunnelService = server.ReverseTunnelService + endpointHandler.SettingsService = server.SettingsService var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) endpointGroupHandler.EndpointGroupService = server.EndpointGroupService @@ -140,6 +144,8 @@ func (server *Server) Start() error { var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) endpointProxyHandler.EndpointService = server.EndpointService endpointProxyHandler.ProxyManager = proxyManager + endpointProxyHandler.SettingsService = server.SettingsService + endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) @@ -168,6 +174,7 @@ func (server *Server) Start() error { schedulesHandler.JobService = server.JobService schedulesHandler.JobScheduler = server.JobScheduler schedulesHandler.SettingsService = server.SettingsService + schedulesHandler.ReverseTunnelService = server.ReverseTunnelService var settingsHandler = settings.NewHandler(requestBouncer) settingsHandler.SettingsService = server.SettingsService @@ -216,6 +223,7 @@ func (server *Server) Start() error { var websocketHandler = websocket.NewHandler(requestBouncer) websocketHandler.EndpointService = server.EndpointService websocketHandler.SignatureService = server.SignatureService + websocketHandler.ReverseTunnelService = server.ReverseTunnelService var webhookHandler = webhooks.NewHandler(requestBouncer) webhookHandler.WebhookService = server.WebhookService diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go index bcd211a6b..01de1cd20 100644 --- a/api/libcompose/compose_stack.go +++ b/api/libcompose/compose_stack.go @@ -2,6 +2,7 @@ package libcompose import ( "context" + "fmt" "path" "path/filepath" @@ -17,19 +18,28 @@ import ( // ComposeStackManager represents a service for managing compose stacks. type ComposeStackManager struct { - dataPath string + dataPath string + reverseTunnelService portainer.ReverseTunnelService } // NewComposeStackManager initializes a new ComposeStackManager service. -func NewComposeStackManager(dataPath string) *ComposeStackManager { +func NewComposeStackManager(dataPath string, reverseTunnelService portainer.ReverseTunnelService) *ComposeStackManager { return &ComposeStackManager{ - dataPath: dataPath, + dataPath: dataPath, + reverseTunnelService: reverseTunnelService, } } -func createClient(endpoint *portainer.Endpoint) (client.Factory, error) { +func (manager *ComposeStackManager) createClient(endpoint *portainer.Endpoint) (client.Factory, error) { + + endpointURL := endpoint.URL + if endpoint.Type == portainer.EdgeAgentEnvironment { + tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID) + endpointURL = fmt.Sprintf("tcp://localhost:%d", tunnel.Port) + } + clientOpts := client.Options{ - Host: endpoint.URL, + Host: endpointURL, APIVersion: portainer.SupportedDockerAPIVersion, } @@ -47,7 +57,7 @@ func createClient(endpoint *portainer.Endpoint) (client.Factory, error) { // Up will deploy a compose stack (equivalent of docker-compose up) func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { - clientFactory, err := createClient(endpoint) + clientFactory, err := manager.createClient(endpoint) if err != nil { return err } @@ -85,7 +95,7 @@ func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portain // Down will shutdown a compose stack (equivalent of docker-compose down) func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error { - clientFactory, err := createClient(endpoint) + clientFactory, err := manager.createClient(endpoint) if err != nil { return err } diff --git a/api/portainer.go b/api/portainer.go index 852552ba6..d8152f082 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1,5 +1,7 @@ package portainer +import "time" + type ( // Pair defines a key/value string pair Pair struct { @@ -10,6 +12,8 @@ type ( // CLIFlags represents the available flags on the CLI CLIFlags struct { Addr *string + TunnelAddr *string + TunnelPort *string AdminPassword *string AdminPasswordFile *string Assets *string @@ -105,6 +109,7 @@ type ( SnapshotInterval string `json:"SnapshotInterval"` TemplatesURL string `json:"TemplatesURL"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` + EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"` // Deprecated fields DisplayDonationHeader bool @@ -250,7 +255,8 @@ type ( Snapshots []Snapshot `json:"Snapshots"` UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` - + EdgeID string `json:"EdgeID,omitempty"` + EdgeKey string `json:"EdgeKey"` // Deprecated fields // Deprecated in DBVersion == 4 TLS bool `json:"TLS,omitempty"` @@ -333,11 +339,21 @@ type ( Recurring bool Created int64 JobType JobType + EdgeSchedule *EdgeSchedule ScriptExecutionJob *ScriptExecutionJob SnapshotJob *SnapshotJob EndpointSyncJob *EndpointSyncJob } + // EdgeSchedule represents a scheduled job that can run on Edge environments. + EdgeSchedule struct { + ID ScheduleID `json:"Id"` + CronExpression string `json:"CronExpression"` + Script string `json:"Script"` + Version int `json:"Version"` + Endpoints []EndpointID `json:"Endpoints"` + } + // WebhookID represents a webhook identifier. WebhookID int @@ -575,6 +591,20 @@ type ( Valid bool `json:"Valid,omitempty"` } + // TunnelDetails represents information associated to a tunnel + TunnelDetails struct { + Status string + LastActivity time.Time + Port int + Schedules []EdgeSchedule + Credentials string + } + + // TunnelServerInfo represents information associated to the tunnel server + TunnelServerInfo struct { + PrivateKeySeed string `json:"PrivateKeySeed"` + } + // CLIService represents a service for managing CLI CLIService interface { ParseFlags(version string) (*CLIFlags, error) @@ -692,6 +722,12 @@ type ( StoreDBVersion(version int) error } + // TunnelServerService represents a service for managing data associated to the tunnel server + TunnelServerService interface { + Info() (*TunnelServerInfo, error) + UpdateInfo(info *TunnelServerInfo) error + } + // WebhookService represents a service for managing webhook data. WebhookService interface { Webhooks() ([]Webhook, error) @@ -850,13 +886,25 @@ type ( DisableExtension(extension *Extension) error UpdateExtension(extension *Extension, version string) error } + + // ReverseTunnelService represensts a service used to manage reverse tunnel connections. + ReverseTunnelService interface { + StartTunnelServer(addr, port string, snapshotter Snapshotter) error + GenerateEdgeKey(url, host string, endpointIdentifier int) string + SetTunnelStatusToActive(endpointID EndpointID) + SetTunnelStatusToRequired(endpointID EndpointID) error + SetTunnelStatusToIdle(endpointID EndpointID) + GetTunnelDetails(endpointID EndpointID) *TunnelDetails + AddSchedule(endpointID EndpointID, schedule *EdgeSchedule) + RemoveSchedule(scheduleID ScheduleID) + } ) const ( // APIVersion is the version number of the Portainer API APIVersion = "1.21.0" // DBVersion is the version number of the Portainer database - DBVersion = 18 + DBVersion = 19 // AssetsServerURL represents the URL of the Portainer asset server AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com" // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved @@ -865,6 +913,8 @@ const ( ExtensionDefinitionsURL = AssetsServerURL + "/extensions-1.21.0.json" // PortainerAgentHeader represents the name of the header available in any agent response PortainerAgentHeader = "Portainer-Agent" + // PortainerAgentEdgeIDHeader represent the name of the header containing the Edge ID associated to an agent/agent cluster + PortainerAgentEdgeIDHeader = "X-PortainerAgent-EdgeID" // PortainerAgentTargetHeader represent the name of the header containing the target node name PortainerAgentTargetHeader = "X-PortainerAgent-Target" // PortainerAgentSignatureHeader represent the name of the header containing the digital signature @@ -878,6 +928,8 @@ const ( SupportedDockerAPIVersion = "1.24" // ExtensionServer represents the server used by Portainer to communicate with extensions ExtensionServer = "localhost" + // DefaultEdgeAgentCheckinIntervalInSeconds represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance + DefaultEdgeAgentCheckinIntervalInSeconds = 5 ) const ( @@ -953,6 +1005,8 @@ const ( AgentOnDockerEnvironment // AzureEnvironment represents an endpoint connected to an Azure environment AzureEnvironment + // EdgeAgentEnvironment represents an endpoint connected to an Edge agent + EdgeAgentEnvironment ) const ( @@ -1019,6 +1073,15 @@ const ( CustomRegistry ) +const ( + // EdgeAgentIdle represents an idle state for a tunnel connected to an Edge endpoint. + EdgeAgentIdle string = "IDLE" + // EdgeAgentManagementRequired represents a required state for a tunnel connected to an Edge endpoint + EdgeAgentManagementRequired string = "REQUIRED" + // EdgeAgentActive represents an active state for a tunnel connected to an Edge endpoint + EdgeAgentActive string = "ACTIVE" +) + const ( OperationDockerContainerArchiveInfo Authorization = "DockerContainerArchiveInfo" OperationDockerContainerList Authorization = "DockerContainerList" diff --git a/app/app.js b/app/app.js index 293a5fd3e..acdee2add 100644 --- a/app/app.js +++ b/app/app.js @@ -1,8 +1,8 @@ import _ from 'lodash-es'; angular.module('portainer') -.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper', -function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar, $transitions, HttpRequestHelper) { +.run(['$rootScope', '$state', '$interval', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'SystemService', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper', +function ($rootScope, $state, $interval, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, SystemService, cfpLoadingBar, $transitions, HttpRequestHelper) { 'use strict'; EndpointProvider.initialize(); @@ -34,8 +34,20 @@ function ($rootScope, $state, Authentication, authManager, StateManager, Endpoin $transitions.onBefore({}, function() { HttpRequestHelper.resetAgentHeaders(); }); + + // Keep-alive Edge endpoints by sending a ping request every minute + $interval(function() { + ping(EndpointProvider, SystemService); + }, 60 * 1000) + }]); +function ping(EndpointProvider, SystemService) { + let endpoint = EndpointProvider.currentEndpoint(); + if (endpoint !== undefined && endpoint.Type === 4) { + SystemService.ping(endpoint.Id); + } +} function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js index a31d5b4ca..23f5c8e72 100644 --- a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js @@ -13,6 +13,7 @@ angular.module('portainer.docker') showQuickActionLogs: true, showQuickActionConsole: true, showQuickActionInspect: true, + showQuickActionExec: true, showQuickActionAttach: false }); diff --git a/app/docker/helpers/infoHelper.js b/app/docker/helpers/infoHelper.js index c16221375..05f00012e 100644 --- a/app/docker/helpers/infoHelper.js +++ b/app/docker/helpers/infoHelper.js @@ -13,7 +13,7 @@ angular.module('portainer.docker') agentProxy: false }; - if (type === 2) { + if (type === 2 || type === 4) { mode.agentProxy = true; } diff --git a/app/docker/rest/container.js b/app/docker/rest/container.js index 94c3befbc..1aeb38e26 100644 --- a/app/docker/rest/container.js +++ b/app/docker/rest/container.js @@ -1,4 +1,4 @@ -import { logsHandler, genericHandler } from "./response/handlers"; +import {genericHandler, logsHandler} from './response/handlers'; angular.module('portainer.docker') .factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'ContainersInterceptor', @@ -11,7 +11,7 @@ function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, C { query: { method: 'GET', params: { all: 0, action: 'json', filters: '@filters' }, - isArray: true, interceptor: ContainersInterceptor, timeout: 10000 + isArray: true, interceptor: ContainersInterceptor, timeout: 15000 }, get: { method: 'GET', params: { action: 'json' } diff --git a/app/docker/rest/image.js b/app/docker/rest/image.js index fff8921d7..928466802 100644 --- a/app/docker/rest/image.js +++ b/app/docker/rest/image.js @@ -1,5 +1,5 @@ -import { jsonObjectsToArrayHandler, deleteImageHandler } from './response/handlers'; -import { imageGetResponse } from './response/image'; +import {deleteImageHandler, jsonObjectsToArrayHandler} from './response/handlers'; +import {imageGetResponse} from './response/image'; angular.module('portainer.docker') .factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', 'ImagesInterceptor', @@ -10,7 +10,7 @@ function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpR endpointId: EndpointProvider.endpointID }, { - query: {method: 'GET', params: {all: 0, action: 'json'}, isArray: true, interceptor: ImagesInterceptor, timeout: 10000}, + query: {method: 'GET', params: {all: 0, action: 'json'}, isArray: true, interceptor: ImagesInterceptor, timeout: 15000}, get: {method: 'GET', params: {action: 'json'}}, search: {method: 'GET', params: {action: 'search'}}, history: {method: 'GET', params: {action: 'history'}, isArray: true}, diff --git a/app/docker/rest/network.js b/app/docker/rest/network.js index 192a442fe..5830483ef 100644 --- a/app/docker/rest/network.js +++ b/app/docker/rest/network.js @@ -1,4 +1,4 @@ -import { genericHandler } from './response/handlers'; +import {genericHandler} from './response/handlers'; angular.module('portainer.docker') .factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'NetworksInterceptor', @@ -10,7 +10,7 @@ function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, Net }, { query: { - method: 'GET', isArray: true, interceptor: NetworksInterceptor, timeout: 10000 + method: 'GET', isArray: true, interceptor: NetworksInterceptor, timeout: 15000 }, get: { method: 'GET' diff --git a/app/docker/rest/system.js b/app/docker/rest/system.js index 4286bd74f..844ae046f 100644 --- a/app/docker/rest/system.js +++ b/app/docker/rest/system.js @@ -1,4 +1,4 @@ -import { jsonObjectsToArrayHandler } from './response/handlers'; +import {jsonObjectsToArrayHandler} from './response/handlers'; angular.module('portainer.docker') .factory('System', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'InfoInterceptor', 'VersionInterceptor', @@ -10,7 +10,7 @@ angular.module('portainer.docker') }, { info: { - method: 'GET', params: { action: 'info' }, timeout: 10000, interceptor: InfoInterceptor + method: 'GET', params: { action: 'info' }, timeout: 15000, interceptor: InfoInterceptor }, version: { method: 'GET', params: { action: 'version' }, timeout: 4500, interceptor: VersionInterceptor }, events: { diff --git a/app/docker/rest/systemEndpoint.js b/app/docker/rest/systemEndpoint.js index 84ecf6a72..c54b8bda1 100644 --- a/app/docker/rest/systemEndpoint.js +++ b/app/docker/rest/systemEndpoint.js @@ -7,7 +7,7 @@ angular.module('portainer.docker') }, { ping: { - method: 'GET', params: { action: '_ping', endpointId: '@endpointId' }, timeout: 10000 + method: 'GET', params: { action: '_ping', endpointId: '@endpointId' } } }); }]); diff --git a/app/docker/rest/volume.js b/app/docker/rest/volume.js index 6ad6e6bdd..1a85ceef6 100644 --- a/app/docker/rest/volume.js +++ b/app/docker/rest/volume.js @@ -1,4 +1,4 @@ -import { genericHandler } from './response/handlers'; +import {genericHandler} from './response/handlers'; angular.module('portainer.docker') .factory('Volume', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'VolumesInterceptor', @@ -9,7 +9,7 @@ angular.module('portainer.docker') endpointId: EndpointProvider.endpointID }, { - query: { method: 'GET', interceptor: VolumesInterceptor, timeout: 10000}, + query: { method: 'GET', interceptor: VolumesInterceptor, timeout: 15000}, get: { method: 'GET', params: {id: '@id'} }, create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler, ignoreLoadingBar: true}, remove: { diff --git a/app/docker/services/systemService.js b/app/docker/services/systemService.js index 5fa9e8327..9e89300ce 100644 --- a/app/docker/services/systemService.js +++ b/app/docker/services/systemService.js @@ -1,4 +1,4 @@ -import { EventViewModel } from '../models/event'; +import {EventViewModel} from '../models/event'; angular.module('portainer.docker') .factory('SystemService', ['$q', 'System', 'SystemEndpoint', function SystemServiceFactory($q, System, SystemEndpoint) { diff --git a/app/docker/views/services/edit/service.html b/app/docker/views/services/edit/service.html index 6e3765f9b..e4f7ef22a 100644 --- a/app/docker/views/services/edit/service.html +++ b/app/docker/views/services/edit/service.html @@ -71,7 +71,7 @@ ng-model="service.Image" ng-change="updateServiceAttribute(service, 'Image')" id="image_name" disable-authorization="DockerServiceUpdate"> -
+ You can refer to the following documentation to get more information about the supported cron expression format. +
+
+ Edge endpoint schedules are managed by cron
on the underlying host. You need to use a valid cron expression that is different from the documentation above.
+
+ This configuration will be ignored for Edge endpoint schedules. +
+ +/host
folder.
+
+ This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the
+ /host
folder.
+
+ Edge endpoint schedules are managed by cron
on the underlying host. You have full access to the filesystem without having to use the /host
folder.
+