feat(edge): introduce support for Edge agent (#3031)

* feat(edge): fix webconsole and agent deployment command

* feat(edge): display agent features when connected to IoT endpoint

* feat(edge): add -e CAP_HOST_MANAGEMENT=1 to agent command

* feat(edge): add -v /:/host and --name portainer_agent_iot to agent command

* style(endpoint-creation): refactor IoT agent to Edge agent

* refactor(api): rename AgentIoTEnvironment to AgentEdgeEnvironment

* refactor(api): rename AgentIoTEnvironment to AgentEdgeEnvironment

* feat(endpoint-creation): update Edge agent deployment instructions

* feat(edge): wip edge

* feat(edge): refactor key creation

* feat(edge): update deployment instructions

* feat(home): update Edge agent endpoint item

* feat(edge): support dynamic ports

* feat(edge): support sleep/wake and snapshots

* feat(edge): support offline mode

* feat(edge): host job support for Edge endpoints

* feat(edge): introduce STANDBY state

* feat(edge): update Edge agent deployment command

* feat(edge): introduce EDGE_ID support

* feat(edge): update default inactivity interval to 5min

* feat(edge): reload Edge schedules after restart

* fix(edge): fix execution of endpoint job against an Edge endpoint

* fix(edge): fix minor issues with scheduling UI/UX

* feat(edge): introduce EdgeSchedule version management

* feat(edge): switch back to REQUIRED state from ACTIVE on error

* refactor(edge): remove comment

* feat(edge): updated tunnel status management

* feat(edge): fix flickering UI when accessing Edge endpoint from home view

* feat(edge): remove STANDBY status

* fix(edge): fix an issue with console and Swarm endpoint

* fix(edge): fix an issue with stack deployment

* fix(edge): reset timer when applying active status

* feat(edge): add background ping for Edge endpoints

* fix(edge): fix infinite loading loop after Edge endpoint connection failure

* fix(home): fix an issue with merge

* feat(api): remove SnapshotRaw from EndpointList response

* feat(api): add pagination for EndpointList operation

* feat(api): rename last_id query parameter to start

* feat(api): implement filter for EndpointList operation

* fix(edge): prevent a pointer issue after removing an active Edge endpoint

* feat(home): front - endpoint backend pagination (#2990)

* feat(home): endpoint pagination with backend

* feat(api): remove default limit value

* fix(endpoints): fix a minor issue with column span

* fix(endpointgroup-create): fix an issue with endpoint group creation

* feat(app): minor loading optimizations

* refactor(api): small refactor of EndpointList operation

* fix(home): fix minor loading text display issue

* refactor(api): document bolt services functions

* feat(home): minor optimization

* fix(api): replace seek with index scanning for EndpointPaginated

* fix(api): fix invalid starting index issue

* fix(api): first implementation of working filter

* fix(home): endpoints list keeps backend pagination when it needs to

* fix(api): endpoint pagination doesn't drop the first item on pages >=2 anymore

* fix(home): UI flickering on page/filter load/change

* feat(auth): login spinner

* feat(api): support searching in associated endpoint group data

* refactor(api): remove unused API endpoint

* refactor(api): remove comment

* refactor(api): refactor proxy manager

* feat(api): declare EndpointList params as optional

* feat(api): support groupID filter on endpoints route

* feat(api): add new API operations endpointGroupAddEndpoint and endpointGroupDeleteEndpoint

* feat(edge): new icon for Edge agent endpoint

* fix(edge): fix missing exec quick action

* fix(edge): add loading indicator when connecting to Edge endpoint

* feat(edge): disable service webhooks for Edge endpoints

* feat(endpoints): backend pagination for endpoints view (#3004)

* feat(edge): dynamic loading for stack migration feature

* feat(edge): wordwrap edge key

* feat(endpoint-groups): backend pagination support for create and edit

* feat(endpoint-groups): debounce on filter for create/edit views

* feat(endpoint-groups): filter assigned on create view

* (endpoint-groups): unassigned endpoints edit view

* refactor(endpoint-groups): code clean

* feat(endpoint-groups): remove message for Unassigned group

* refactor(websocket): minor refactor associated to Edge agent

* feat(endpoint-group): enable backend pagination (#3017)

* feat(api): support groupID filter on endpoints route

* feat(api): add new API operations endpointGroupAddEndpoint and endpointGroupDeleteEndpoint

* feat(endpoint-groups): backend pagination support for create and edit

* feat(endpoint-groups): debounce on filter for create/edit views

* feat(endpoint-groups): filter assigned on create view

* (endpoint-groups): unassigned endpoints edit view

* refactor(endpoint-groups): code clean

* feat(endpoint-groups): remove message for Unassigned group

* refactor(api): endpoint group endpoint association refactor

* refactor(api): rename files and remove comments

* refactor(api): remove usage of utils

* refactor(api): optional parameters

* Merge branch 'feat-endpoint-backend-pagination' into edge

# Conflicts:
#	api/bolt/endpoint/endpoint.go
#	api/http/handler/endpointgroups/endpointgroup_update.go
#	api/http/handler/endpointgroups/handler.go
#	api/http/handler/endpoints/endpoint_list.go
#	app/portainer/services/api/endpointService.js

* fix(api): fix default tunnel server credentials

* feat(api): update endpointListOperation behavior and parameters

* fix(api): fix interface declaration

* feat(edge): support configurable Edge agent checkin interval

* feat(edge): support dynamic tunnel credentials

* feat(edge): update Edge agent deployment commands

* style(edge): update Edge agent settings text

* refactor(edge): remove unused credentials management methods

* feat(edge): associate a remote addr to tunnel credentials

* style(edge): update Edge endpoint icon

* feat(edge): support encrypted tunnel credentials

* fix(edge): fix invalid pointer cast

* feat(bolt): decode endpoints with jsoniter

* feat(edge): persist reverse tunnel keyseed

* refactor(edge): minor refactor

* feat(edge): update chisel library usage

* refactor(endpoint): use controller function

* feat(api): database migration to DBVersion 19

* refactor(api): refactor AddSchedule function

* refactor(schedules): remove comment

* refactor(api): remove comment

* refactor(api): remove comment

* feat(api): tunnel manager now only manage Edge endpoints

* refactor(api): clean-up and clarification of the Edge service

* refactor(api): clean-up and clarification of the Edge service

* fix(api): fix an issue with Edge agent snapshots

* refactor(api): add missing comments

* refactor(api): update constant description

* style(home): remove loading text on error

* feat(endpoint): remove 15s timeout for ping request

* style(home): display information about associated Edge endpoints

* feat(home): redirect to endpoint details on click on unassociated Edge endpoint

* feat(settings): remove 60s Edge poll frequency option
pull/3052/head
Anthony Lapenna 2019-07-26 10:38:07 +12:00 committed by GitHub
parent 2252ab9da7
commit 12a512f01f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
86 changed files with 1568 additions and 225 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

24
api/chisel/key.go Normal file
View File

@ -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))
}

47
api/chisel/schedules.go Normal file
View File

@ -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)
}
}

191
api/chisel/service.go Normal file
View File

@ -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)
}

144
api/chisel/tunnel.go Normal file
View File

@ -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
}

View File

@ -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(),

View File

@ -4,6 +4,8 @@ package cli
const (
defaultBindAddress = ":9000"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "/data"
defaultAssetsDirectory = "./"
defaultNoAuth = "false"

View File

@ -2,6 +2,8 @@ package cli
const (
defaultBindAddress = ":9000"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "C:\\data"
defaultAssetsDirectory = "./"
defaultNoAuth = "false"

View File

@ -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,

View File

@ -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
}

View File

@ -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)

View File

@ -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)
}

View File

@ -1,6 +1,7 @@
package docker
import (
"fmt"
"net/http"
"strings"
"time"
@ -18,12 +19,14 @@ const (
// ClientFactory is used to create Docker clients
type ClientFactory struct {
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,
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 {

View File

@ -3,6 +3,7 @@ package exec
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path"
@ -17,16 +18,18 @@ type SwarmStackManager struct {
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,
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")

View File

@ -13,7 +13,9 @@ type Handler struct {
*mux.Router
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.

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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,

View File

@ -18,6 +18,7 @@ type Handler struct {
FileService portainer.FileService
JobService portainer.JobService
JobScheduler portainer.JobScheduler
ReverseTunnelService portainer.ReverseTunnelService
}
// NewHandler creates a handler to manage schedule operations.

View File

@ -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

View File

@ -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))

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -13,6 +13,7 @@ type Handler struct {
*mux.Router
EndpointService portainer.EndpointService
SignatureService portainer.DigitalSignatureService
ReverseTunnelService portainer.ReverseTunnelService
requestBouncer *security.RequestBouncer
connectionUpgrader websocket.Upgrader
}

View File

@ -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

View File

@ -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) {

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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)
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.newDockerHTTPProxy(endpointURL, false, endpointID), nil
}
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)
}

View File

@ -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

View File

@ -2,6 +2,7 @@ package libcompose
import (
"context"
"fmt"
"path"
"path/filepath"
@ -18,18 +19,27 @@ import (
// ComposeStackManager represents a service for managing compose stacks.
type ComposeStackManager struct {
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,
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
}

View File

@ -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"

View File

@ -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();

View File

@ -13,6 +13,7 @@ angular.module('portainer.docker')
showQuickActionLogs: true,
showQuickActionConsole: true,
showQuickActionInspect: true,
showQuickActionExec: true,
showQuickActionAttach: false
});

View File

@ -13,7 +13,7 @@ angular.module('portainer.docker')
agentProxy: false
};
if (type === 2) {
if (type === 2 || type === 4) {
mode.agentProxy = true;
}

View File

@ -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' }

View File

@ -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},

View File

@ -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'

View File

@ -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: {

View File

@ -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' }
}
});
}]);

View File

@ -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: {

View File

@ -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) {

View File

@ -71,7 +71,7 @@
ng-model="service.Image" ng-change="updateServiceAttribute(service, 'Image')" id="image_name" disable-authorization="DockerServiceUpdate">
</td>
</tr>
<tr>
<tr ng-if="applicationState.endpoint.type !== 4">
<td colspan="{{webhookURL ? '1' : '2'}}">
Service webhook
<portainer-tooltip position="top" message="Webhook (or callback URI) used to automate the update of this service. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this service."></portainer-tooltip>

View File

@ -1,7 +1,7 @@
import _ from "lodash-es";
import angular from "angular";
import _ from 'lodash-es';
import angular from 'angular';
import AccessViewerPolicyModel from '../../models/access'
import AccessViewerPolicyModel from '../../models/access';
class AccessViewerController {
/* @ngInject */

View File

@ -51,15 +51,19 @@
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))">
<td>
{{ item.Endpoint.Name }}
<a ng-if="item.Edge" ng-click="$ctrl.getEdgeTaskLogs(item.EndpointId, item.Id)"><i class="fa fa-download" aria-hidden="true" style="margin-left: 5px; margin-right: 2px;"></i> Download logs</a>
</td>
<td>
<a ng-click="$ctrl.goToContainerLogs(item.EndpointId, item.Id)">{{ item.Id | truncate: 32 }}</a>
<a ng-if="!item.Edge" ng-click="$ctrl.goToContainerLogs(item.EndpointId, item.Id)">{{ item.Id | truncate: 32 }}</a>
<span ng-if="item.Edge">-</span>
</td>
<td>
<span class="label label-{{ item.Status|containerstatusbadge }}">{{ item.Status }}</span>
<span ng-if="!item.Edge" class="label label-{{ item.Status|containerstatusbadge }}">{{ item.Status }}</span>
<span ng-if="item.Edge">-</span>
</td>
<td>
{{ item.Created | getisodatefromtimestamp}}
<span ng-if="!item.Edge">{{ item.Created | getisodatefromtimestamp}}</span>
<span ng-if="item.Edge">-</span>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">

View File

@ -8,6 +8,7 @@ angular.module('portainer.docker').component('scheduleTasksDatatable', {
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
goToContainerLogs: '<'
goToContainerLogs: '<',
getEdgeTaskLogs: '<'
}
});

View File

@ -1,7 +1,8 @@
<div class="blocklist-item" ng-click="$ctrl.onSelect($ctrl.model)">
<div class="blocklist-item-box">
<span ng-class="['blocklist-item-logo', 'endpoint-item', { azure: $ctrl.model.Type === 3 }]">
<i ng-class="$ctrl.model.Type | endpointtypeicon" class="fa-4x blue-icon" aria-hidden="true"></i>
<i ng-if="$ctrl.model.Type !== 4" ng-class="$ctrl.model.Type | endpointtypeicon" class="fa-4x blue-icon" aria-hidden="true"></i>
<img ng-if="$ctrl.model.Type === 4" src="../../../../../assets/images/edge_endpoint.png" />
</span>
<span class="col-sm-12">
@ -12,13 +13,16 @@
{{ $ctrl.model.Name }}
</span>
<span class="space-left blocklist-item-subtitle">
<span class="label label-{{ $ctrl.model.Status|endpointstatusbadge }}">
<span ng-if="$ctrl.model.Type === 4" class="small text-muted">
<span ng-if="$ctrl.model.EdgeID"><i class="fas fa-link"></i> associated</span>
<span ng-if="!$ctrl.model.EdgeID"><i class="fas fa-unlink"></i> <s>associated</s></span>
</span>
<span class="label label-{{ $ctrl.model.Status|endpointstatusbadge }}" ng-if="$ctrl.model.Type !== 4">
{{ $ctrl.model.Status === 1 ? 'up' : 'down' }}
</span>
<span class="space-left small text-muted" ng-if="$ctrl.model.Snapshots[0]">
{{ $ctrl.model.Snapshots[0].Time | getisodatefromtimestamp }}
</span>
</span>
</span>
<span>
@ -64,6 +68,12 @@
</span>
</div>
<div class="blocklist-item-line endpoint-item" ng-if="!$ctrl.model.Snapshots[0]">
<span class="blocklist-item-desc">
No snapshot available
</span>
</div>
<div class="blocklist-item-line endpoint-item">
<span class="small text-muted">
<span ng-if="$ctrl.model.Type === 1">
@ -82,7 +92,7 @@
</span>
</span>
</span>
<span class="small text-muted">
<span class="small text-muted" ng-if="$ctrl.model.Type !== 4">
{{ $ctrl.model.URL | stripprotocol }}
</span>
</div>

View File

@ -89,7 +89,6 @@ angular.module('portainer.app').controller('EndpointListController', ['Datatable
}
this.$onInit = function() {
this.state.loading = true;
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
this.state.paginatedItemLimit = PaginationService.getPaginationLimit(this.tableKey);
if (textFilter !== null) {

View File

@ -25,7 +25,7 @@ angular.module('portainer.app').component('scheduleForm', {
ctrl.formValues = {
datetime: ctrl.model.CronExpression ? cronToDatetime(ctrl.model.CronExpression) : moment(),
scheduleValue: ctrl.scheduleValues[0],
cronMethod: 'basic'
cronMethod: ctrl.model.Recurring ? 'advanced' : 'basic'
};
function cronToDatetime(cron) {
@ -38,7 +38,7 @@ angular.module('portainer.app').component('scheduleForm', {
function datetimeToCron(datetime) {
var date = moment(datetime);
return '0 '.concat(date.minutes(), ' ', date.hours(), ' ', date.date(), ' ', (date.month() + 1));
return '0 '.concat(date.minutes(), ' ', date.hours(), ' ', date.date(), ' ', (date.month() + 1), ' *');
}
this.action = function() {

View File

@ -1,4 +1,15 @@
<form class="form-horizontal" name="scheduleForm">
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
<p>
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i> Due to how schedules behave differently on Edge endpoints and other endpoints it is recommended to create specific schedules that will only target one
type of endpoint.
</p>
</span>
</div>
<div class="col-sm-12 form-section-title">
Schedule configuration
</div>
@ -114,7 +125,12 @@
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
<p>
You can refer to the <a href="https://godoc.org/github.com/robfig/cron#hdr-CRON_Expression_Format" target="_blank">following documentation</a> to get more information about the supported cron expression format.
</p>
<p>
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i> Edge endpoint schedules are managed by <code>cron</code> on the underlying host. You need to use a valid cron expression that is different from the documentation above.
</p>
</span>
</div>
</div>
@ -123,6 +139,13 @@
<div class="col-sm-12 form-section-title">
Job configuration
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
<p>
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i> This configuration will be ignored for Edge endpoint schedules.
</p>
</span>
</div>
<!-- image-input -->
<div class="form-group">
<label for="schedule_image" class="col-sm-2 control-label text-left">Image</label>
@ -195,8 +218,13 @@
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
<p>
This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the
<code>/host</code> folder.
</p>
<p>
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i> Edge endpoint schedules are managed by <code>cron</code> on the underlying host. You have full access to the filesystem without having to use the <code>/host</code> folder.
</p>
</span>
</div>
<div class="form-group">

View File

@ -124,6 +124,8 @@ angular.module('portainer.app')
return 'Agent';
} else if (type === 3) {
return 'Azure ACI';
} else if (type === 4) {
return 'Edge Agent';
}
return '';
};
@ -133,6 +135,8 @@ angular.module('portainer.app')
return function (type) {
if (type === 3) {
return 'fab fa-microsoft';
} else if (type === 4) {
return 'fa fa-cloud';
}
return 'fab fa-docker';
};

View File

@ -1,4 +1,5 @@
import { createStatus } from '../../docker/models/container';
import _ from 'lodash-es';
import {createStatus} from '../../docker/models/container';
export function ScheduleDefaultModel() {
this.Name = '';
@ -9,7 +10,7 @@ export function ScheduleDefaultModel() {
}
function ScriptExecutionDefaultJobModel() {
this.Image = '';
this.Image = 'ubuntu:latest';
this.Endpoints = [];
this.FileContent = '';
this.File = null;
@ -23,14 +24,20 @@ export function ScheduleModel(data) {
this.JobType = data.JobType;
this.CronExpression = data.CronExpression;
this.Created = data.Created;
this.EdgeSchedule = data.EdgeSchedule;
if (this.JobType === 1) {
this.Job = new ScriptExecutionJobModel(data.ScriptExecutionJob);
this.Job = new ScriptExecutionJobModel(data.ScriptExecutionJob, data.EdgeSchedule);
}
}
function ScriptExecutionJobModel(data) {
function ScriptExecutionJobModel(data, edgeSchedule) {
this.Image = data.Image;
this.Endpoints = data.Endpoints;
if (edgeSchedule !== null) {
this.Endpoints = _.concat(data.Endpoints, edgeSchedule.Endpoints);
}
this.FileContent = '';
this.Method = 'editor';
this.RetryCount = data.RetryCount;
@ -42,6 +49,7 @@ export function ScriptExecutionTaskModel(data) {
this.EndpointId = data.EndpointId;
this.Status = createStatus(data.Status);
this.Created = data.Created;
this.Edge = data.Edge;
}
export function ScheduleCreateRequest(model) {

View File

@ -10,6 +10,7 @@ export function SettingsViewModel(data) {
this.TemplatesURL = data.TemplatesURL;
this.ExternalTemplates = data.ExternalTemplates;
this.EnableHostManagementFeatures = data.EnableHostManagementFeatures;
this.EdgeAgentCheckinInterval = data.EdgeAgentCheckinInterval;
}
export function PublicSettingsViewModel(settings) {

View File

@ -13,8 +13,9 @@ angular.module('portainer.app')
update: { method: 'PUT', params: { id: '@id' } },
updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } },
remove: { method: 'DELETE', params: { id: '@id'} },
snapshots: { method: 'POST', params: { action: 'snapshot' }},
snapshot: { method: 'POST', params: { id: '@id', action: 'snapshot' }},
executeJob: { method: 'POST', ignoreLoadingBar: true, params: { id: '@id', action: 'job' } }
snapshots: { method: 'POST', params: { action: 'snapshot' } },
snapshot: { method: 'POST', params: { id: '@id', action: 'snapshot' } },
executeJob: { method: 'POST', ignoreLoadingBar: true, params: { id: '@id', action: 'job' } },
status: { method: 'GET', params: { id: '@id', action: 'status' } }
});
}]);

View File

@ -66,7 +66,12 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
service.createRemoteEndpoint = function(name, type, URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
var deferred = $q.defer();
FileUploadService.createEndpoint(name, type, 'tcp://' + URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
var endpointURL = URL;
if (type !== 4) {
endpointURL = 'tcp://' + URL;
}
FileUploadService.createEndpoint(name, type, endpointURL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
.then(function success(response) {
deferred.resolve(response.data);
})

View File

@ -1,8 +1,4 @@
import {
EndpointGroupModel,
EndpointGroupCreateRequest,
EndpointGroupUpdateRequest
} from '../../models/group';
import {EndpointGroupCreateRequest, EndpointGroupModel, EndpointGroupUpdateRequest} from '../../models/group';
angular.module('portainer.app')
.factory('GroupService', ['$q', 'EndpointGroups',

View File

@ -171,6 +171,7 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin
var endpointAPIVersion = parseFloat(data.version.ApiVersion);
state.endpoint.mode = endpointMode;
state.endpoint.name = endpoint.Name;
state.endpoint.type = endpoint.Type;
state.endpoint.apiVersion = endpointAPIVersion;
state.endpoint.extensions = assignExtensions(extensions);

View File

@ -1,4 +1,4 @@
import { EndpointSecurityFormData } from '../../../components/endpointSecurity/porEndpointSecurityModel';
import {EndpointSecurityFormData} from '../../../components/endpointSecurity/porEndpointSecurityModel';
angular.module('portainer.app')
.controller('CreateEndpointController', ['$q', '$scope', '$state', '$filter', 'clipboard', 'EndpointService', 'GroupService', 'TagService', 'Notifications',
@ -27,6 +27,14 @@ function ($q, $scope, $state, $filter, clipboard, EndpointService, GroupService,
$('#copyNotification').fadeOut(2000);
};
$scope.setDefaultPortainerInstanceURL = function() {
$scope.formValues.URL = window.location.origin;
};
$scope.resetEndpointURL = function() {
$scope.formValues.URL = '';
};
$scope.addDockerEndpoint = function() {
var name = $scope.formValues.Name;
var URL = $filter('stripprotocol')($scope.formValues.URL);
@ -56,6 +64,15 @@ function ($q, $scope, $state, $filter, clipboard, EndpointService, GroupService,
addEndpoint(name, 2, URL, publicURL, groupId, tags, true, true, true, null, null, null);
};
$scope.addEdgeAgentEndpoint = function() {
var name = $scope.formValues.Name;
var groupId = $scope.formValues.GroupId;
var tags = $scope.formValues.Tags;
var URL = $scope.formValues.URL;
addEndpoint(name, 4, URL, "", groupId, tags, false, false, false, null, null, null);
};
$scope.addAzureEndpoint = function() {
var name = $scope.formValues.Name;
var applicationId = $scope.formValues.AzureApplicationId;
@ -85,9 +102,13 @@ function ($q, $scope, $state, $filter, clipboard, EndpointService, GroupService,
function addEndpoint(name, type, URL, PublicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
$scope.state.actionInProgress = true;
EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
.then(function success() {
.then(function success(data) {
Notifications.success('Endpoint created', name);
if (type === 4) {
$state.go('portainer.endpoints.endpoint', { id: data.Id });
} else {
$state.go('portainer.endpoints', {}, {reload: true});
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create endpoint');

View File

@ -16,7 +16,7 @@
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<div ng-click="resetEndpointURL()">
<input type="radio" id="docker_endpoint" ng-model="state.EnvironmentType" value="docker">
<label for="docker_endpoint">
<div class="boxselector_header">
@ -26,7 +26,7 @@
<p>Docker environment</p>
</label>
</div>
<div>
<div ng-click="resetEndpointURL()">
<input type="radio" id="agent_endpoint" ng-model="state.EnvironmentType" value="agent">
<label for="agent_endpoint">
<div class="boxselector_header">
@ -36,6 +36,16 @@
<p>Portainer agent</p>
</label>
</div>
<div ng-click="setDefaultPortainerInstanceURL()">
<input type="radio" id="edge_agent_endpoint" ng-model="state.EnvironmentType" value="edge_agent">
<label for="edge_agent_endpoint">
<div class="boxselector_header">
<i class="fa fa-cloud" aria-hidden="true" style="margin-right: 2px;"></i>
Edge Agent
</div>
<p>Portainer Edge agent</p>
</label>
</div>
<div>
<input type="radio" id="azure_endpoint" ng-model="state.EnvironmentType" value="azure">
<label for="azure_endpoint">
@ -77,6 +87,16 @@
</span>
</div>
</div>
<div ng-if="state.EnvironmentType === 'edge_agent'">
<div class="col-sm-12 form-section-title" >
Information
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Allows you to create an endpoint that can be registered with an Edge agent. The Edge agent will initiate the communications with the Portainer instance. All the required information on how to connect an Edge agent to this endpoint will be available after endpoint creation.
</span>
</div>
</div>
<div ng-if="state.EnvironmentType === 'azure'">
<div class="col-sm-12 form-section-title" >
Information
@ -138,6 +158,26 @@
</div>
</div>
<!-- !endpoint-url-input -->
<!-- portainer-instance-input -->
<div ng-if="state.EnvironmentType === 'edge_agent'">
<div class="form-group">
<label for="endpoint_url" class="col-sm-3 col-lg-2 control-label text-left">
Portainer server URL
<portainer-tooltip position="bottom" message="URL of the Portainer instance that the agent will use to initiate the communications."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" name="endpoint_url" ng-model="formValues.URL" placeholder="e.g. 10.0.0.10:9000 or portainer.mydomain.com" required>
</div>
</div>
<div class="form-group" ng-show="endpointCreationForm.endpoint_url.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="endpointCreationForm.endpoint_url.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
</div>
<!-- !portainer-instance-input -->
<!-- endpoint-public-url-input -->
<div ng-if="state.EnvironmentType === 'docker' || state.EnvironmentType === 'agent'">
<div class="form-group">
@ -238,6 +278,10 @@
<span ng-hide="state.actionInProgress"><i class="fa fa-plus" aria-hidden="true"></i> Add endpoint</span>
<span ng-show="state.actionInProgress">Creating endpoint...</span>
</button>
<button ng-if="state.EnvironmentType === 'edge_agent'" type="submit" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !endpointCreationForm.$valid" ng-click="addEdgeAgentEndpoint()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress"><i class="fa fa-plus" aria-hidden="true"></i> Add endpoint</span>
<span ng-show="state.actionInProgress">Creating endpoint...</span>
</button>
<button ng-if="state.EnvironmentType === 'azure'" type="submit" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !endpointCreationForm.$valid" ng-click="addAzureEndpoint()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress"><i class="fa fa-plus" aria-hidden="true"></i> Add endpoint</span>
<span ng-show="state.actionInProgress">Creating endpoint...</span>

View File

@ -1,10 +1,108 @@
<rd-header>
<rd-header-title title-text="Endpoint details"></rd-header-title>
<rd-header-title title-text="Endpoint details">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.endpoints.endpoint({id: endpoint.Id})" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>
<a ui-sref="portainer.endpoints">Endpoints</a> &gt; <a ui-sref="portainer.endpoints.endpoint({id: endpoint.Id})">{{ endpoint.Name }}</a>
</rd-header-content>
</rd-header>
<div class="row">
<information-panel ng-if="endpoint.Type === 4 && endpoint.EdgeID" title-text="Edge information">
<span class="small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This Edge endpoint is associated to an Edge environment.
</p>
<p>
Edge key: <code>{{ endpoint.EdgeKey }}</code>
</p>
<p>
Edge identifier: <code>{{ endpoint.EdgeID }}</code>
</p>
</span>
</information-panel>
<information-panel ng-if="endpoint.Type === 4 && !endpoint.EdgeID" title-text="Deploy an agent">
<span class="small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Deploy the Edge agent on your remote Docker environment using the following command
</p>
<div style="margin-top: 10px;">
<uib-tabset active="state.deploymentTab">
<uib-tab index="0" heading="Standalone">
<code style=display:block;white-space:pre-wrap>
docker run -d -v /var/run/docker.sock:/var/run/docker.sock \
-v /var/lib/docker/volumes:/var/lib/docker/volumes \
-v /:/host \
--restart always \
-e EDGE=1 \
-e EDGE_ID={{ randomEdgeID }} \
-e CAP_HOST_MANAGEMENT=1 \
-p 8000:80 \
-v portainer_agent_data:/data \
--name portainer_edge_agent \
portainer/agent
</code>
</uib-tab>
<uib-tab index="1" heading="Swarm">
<code style=display:block;white-space:pre-wrap>
docker network create \
--driver overlay \
portainer_agent_network;
docker service create \
--name portainer_edge_agent \
--network portainer_agent_network \
-e AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent \
-e EDGE=1 \
-e EDGE_ID={{ randomEdgeID }} \
-e CAP_HOST_MANAGEMENT=1 \
--mode global \
--publish mode=host,published=8000,target=80 \
--constraint 'node.platform.os == linux' \
--mount type=bind,src=//var/run/docker.sock,dst=/var/run/docker.sock \
--mount type=bind,src=//var/lib/docker/volumes,dst=/var/lib/docker/volumes \
--mount type=bind,src=//,dst=/host \
--mount type=volume,src=portainer_agent_data,dst=/data \
portainer/agent
</code>
</uib-tab>
</uib-tabset>
<div style="margin-top: 10px;">
<span class="btn btn-primary btn-sm" ng-click="copyEdgeAgentDeploymentCommand()"><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy command</span>
<span id="copyNotificationDeploymentCommand" style="margin-left: 7px; display: none; color: #23ae89;">
<i class="fa fa-check" aria-hidden="true" ></i> copied
</span>
</div>
</div>
<div class="col-sm-12 form-section-title" style="margin-top: 25px;">
Join token
</div>
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Use the following join token to associate the Edge agent with this endpoint
</p>
<p>
The agent will communicate with Portainer via <u>{{ edgeKeyDetails.instanceURL }}</u> and <u>tcp://{{ edgeKeyDetails.tunnelServerAddr }}</u>
</p>
<div style="margin-top: 10px; overflow-wrap: break-word;">
<code>
{{ endpoint.EdgeKey }}
</code>
<div style="margin-top: 10px;">
<span class="btn btn-primary btn-sm" ng-click="copyEdgeAgentKey()"><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy token</span>
<span id="copyNotificationEdgeKey" style="margin-left: 7px; display: none; color: #23ae89;">
<i class="fa fa-check" aria-hidden="true" ></i> copied
</span>
</div>
</div>
</span>
</information-panel>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
@ -22,7 +120,7 @@
</div>
<!-- !name-input -->
<!-- endpoint-url-input -->
<div class="form-group">
<div class="form-group" ng-if="endpoint.Type !== 4">
<label for="endpoint_url" class="col-sm-3 col-lg-2 control-label text-left">
Endpoint URL
<portainer-tooltip position="bottom" message="URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it."></portainer-tooltip>
@ -70,7 +168,7 @@
</div>
<!-- !tags -->
<!-- endpoint-security -->
<div ng-if="endpointType === 'remote' && endpoint.Type !== 3">
<div ng-if="endpointType === 'remote' && endpoint.Type !== 3 && endpoint.Type !== 4">
<div class="col-sm-12 form-section-title">
Security
</div>

View File

@ -1,8 +1,10 @@
import { EndpointSecurityFormData } from '../../../components/endpointSecurity/porEndpointSecurityModel';
import _ from 'lodash-es';
import uuidv4 from 'uuid/v4';
import {EndpointSecurityFormData} from '../../../components/endpointSecurity/porEndpointSecurityModel';
angular.module('portainer.app')
.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'TagService', 'EndpointProvider', 'Notifications',
function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, TagService, EndpointProvider, Notifications) {
.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'clipboard', 'EndpointService', 'GroupService', 'TagService', 'EndpointProvider', 'Notifications',
function ($q, $scope, $state, $transition$, $filter, clipboard, EndpointService, GroupService, TagService, EndpointProvider, Notifications) {
if (!$scope.applicationState.application.endpointManagement) {
$state.go('portainer.endpoints');
@ -10,13 +12,28 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi
$scope.state = {
uploadInProgress: false,
actionInProgress: false
actionInProgress: false,
deploymentTab: 0
};
$scope.formValues = {
SecurityFormData: new EndpointSecurityFormData()
};
$scope.copyEdgeAgentDeploymentCommand = function() {
if ($scope.state.deploymentTab === 0) {
clipboard.copyText('docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v /var/lib/docker/volumes:/var/lib/docker/volumes -v /:/host --restart always -e EDGE=1 -e EDGE_ID=' + $scope.randomEdgeID +' -e CAP_HOST_MANAGEMENT=1 -p 8000:80 -v portainer_agent_data:/data --name portainer_edge_agent portainer/agent');
} else {
clipboard.copyText('docker network create --driver overlay portainer_agent_network; docker service create --name portainer_edge_agent --network portainer_agent_network -e AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent -e EDGE=1 -e EDGE_ID=' + $scope.randomEdgeID +' -e CAP_HOST_MANAGEMENT=1 --mode global --publish mode=host,published=8000,target=80 --constraint \'node.platform.os == linux\' --mount type=bind,src=//var/run/docker.sock,dst=/var/run/docker.sock --mount type=bind,src=//var/lib/docker/volumes,dst=/var/lib/docker/volume --mount type=bind,src=//,dst=/host --mount type=volume,src=portainer_agent_data,dst=/data portainer/agent');
}
$('#copyNotificationDeploymentCommand').show().fadeOut(2500);
};
$scope.copyEdgeAgentKey = function() {
clipboard.copyText($scope.endpoint.EdgeKey);
$('#copyNotificationEdgeKey').show().fadeOut(2500);
};
$scope.updateEndpoint = function() {
var endpoint = $scope.endpoint;
var securityData = $scope.formValues.SecurityFormData;
@ -61,6 +78,20 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi
});
};
function decodeEdgeKey(key) {
let keyInformation = {};
if (key === "") {
return keyInformation;
}
let decodedKey = _.split(atob(key), "|");
keyInformation.instanceURL = decodedKey[0];
keyInformation.tunnelServerAddr = decodedKey[1];
return keyInformation;
}
function initView() {
$q.all({
endpoint: EndpointService.endpoint($transition$.params().id),
@ -75,6 +106,10 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi
$scope.endpointType = 'remote';
}
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
if (endpoint.Type === 4) {
$scope.edgeKeyDetails = decodeEdgeKey(endpoint.EdgeKey);
$scope.randomEdgeID = uuidv4();
}
$scope.endpoint = endpoint;
$scope.groups = data.groups;
$scope.availableTags = data.tags;

View File

@ -24,7 +24,12 @@
</span>
</information-panel>
<div class="row">
<div class="row" style="width:100%; height:100%; text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center;" ng-if="state.connectingToEdgeEndpoint">
Connecting to the Edge endpoint...
<i class="fa fa-cog fa-spin" style="margin-left: 5px"></i>
</div>
<div class="row" ng-if="!state.connectingToEdgeEndpoint">
<div class="col-sm-12">
<endpoint-list
title-text="Endpoints" title-icon="fa-plug"

View File

@ -1,6 +1,10 @@
angular.module('portainer.app')
.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'LegacyExtensionManager', 'ModalService', 'MotdService', 'SystemService',
function($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, LegacyExtensionManager, ModalService, MotdService, SystemService) {
.controller('HomeController', ['$q', '$scope', '$state', '$interval', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'LegacyExtensionManager', 'ModalService', 'MotdService', 'SystemService',
function($q, $scope, $state, $interval, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, LegacyExtensionManager, ModalService, MotdService, SystemService) {
$scope.state = {
connectingToEdgeEndpoint: false,
};
$scope.goToEdit = function(id) {
$state.go('portainer.endpoints.endpoint', { id: id });
@ -9,10 +13,12 @@ angular.module('portainer.app')
$scope.goToDashboard = function(endpoint) {
if (endpoint.Type === 3) {
return switchToAzureEndpoint(endpoint);
} else if (endpoint.Type === 4) {
return switchToEdgeEndpoint(endpoint);
}
checkEndpointStatus(endpoint)
.then(function sucess() {
.then(function success() {
return switchToDockerEndpoint(endpoint);
}).catch(function error(err) {
Notifications.error('Failure', err, 'Unable to verify endpoint status');
@ -41,7 +47,7 @@ angular.module('portainer.app')
var status = 1;
SystemService.ping(endpoint.Id)
.then(function sucess() {
.then(function success() {
status = 1;
}).catch(function error() {
status = 2;
@ -52,7 +58,7 @@ angular.module('portainer.app')
}
EndpointService.updateEndpoint(endpoint.Id, { Status: status })
.then(function sucess() {
.then(function success() {
deferred.resolve(endpoint);
}).catch(function error(err) {
deferred.reject({ msg: 'Unable to update endpoint status', err: err });
@ -75,11 +81,33 @@ angular.module('portainer.app')
});
}
function switchToEdgeEndpoint(endpoint) {
if (!endpoint.EdgeID) {
$state.go('portainer.endpoints.endpoint', { id: endpoint.Id });
return;
}
$scope.state.connectingToEdgeEndpoint = true;
SystemService.ping(endpoint.Id)
.then(function success() {
endpoint.Status = 1;
})
.catch(function error() {
endpoint.Status = 2;
})
.finally(function final() {
switchToDockerEndpoint(endpoint);
});
}
function switchToDockerEndpoint(endpoint) {
if (endpoint.Status === 2 && endpoint.Snapshots[0] && endpoint.Snapshots[0].Swarm === true) {
$scope.state.connectingToEdgeEndpoint = false;
Notifications.error('Failure', '', 'Endpoint is unreachable. Connect to another swarm manager.');
return;
} else if (endpoint.Status === 2 && !endpoint.Snapshots[0]) {
$scope.state.connectingToEdgeEndpoint = false;
Notifications.error('Failure', '', 'Endpoint is unreachable and there is no snapshot available for offline browsing.');
return;
}
@ -97,6 +125,9 @@ angular.module('portainer.app')
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
})
.finally(function final() {
$scope.state.connectingToEdgeEndpoint = false;
});
}

View File

@ -1,4 +1,4 @@
import { ScheduleDefaultModel } from '../../../models/schedule';
import {ScheduleDefaultModel} from '../../../models/schedule';
angular.module('portainer.app')
.controller('CreateScheduleController', ['$q', '$scope', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService',

View File

@ -56,6 +56,7 @@
table-key="schedule-tasks"
order-by="Status" reverse-order="true"
go-to-container-logs="goToContainerLogs"
get-edge-task-logs="getEdgeTaskLogs"
></schedule-tasks-datatable>
</uib-tab>

View File

@ -1,6 +1,6 @@
angular.module('portainer.app')
.controller('ScheduleController', ['$q', '$scope', '$transition$', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService', 'EndpointProvider',
function ($q, $scope, $transition$, $state, Notifications, EndpointService, GroupService, ScheduleService, EndpointProvider) {
.controller('ScheduleController', ['$q', '$scope', '$transition$', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService', 'EndpointProvider', 'HostBrowserService', 'FileSaver',
function ($q, $scope, $transition$, $state, Notifications, EndpointService, GroupService, ScheduleService, EndpointProvider, HostBrowserService, FileSaver) {
$scope.state = {
actionInProgress: false
@ -8,6 +8,7 @@ function ($q, $scope, $transition$, $state, Notifications, EndpointService, Grou
$scope.update = update;
$scope.goToContainerLogs = goToContainerLogs;
$scope.getEdgeTaskLogs = getEdgeTaskLogs;
function update() {
var model = $scope.schedule;
@ -31,6 +32,26 @@ function ($q, $scope, $transition$, $state, Notifications, EndpointService, Grou
$state.go('docker.containers.container.logs', { id: containerId });
}
function getEdgeTaskLogs(endpointId, scheduleId) {
var currentId = EndpointProvider.endpointID();
EndpointProvider.setEndpointID(endpointId);
var filePath = '/host/opt/portainer/scripts/' + scheduleId + '.log';
HostBrowserService.get(filePath)
.then(function onFileReceived(data) {
var downloadData = new Blob([data.file], {
type: 'text/plain;charset=utf-8'
});
FileSaver.saveAs(downloadData, scheduleId + '.log');
})
.catch(function notifyOnError(err) {
Notifications.error('Failure', err, 'Unable to download file');
})
.finally(function final() {
EndpointProvider.setEndpointID(currentId);
});
}
function associateEndpointsToTasks(tasks, endpoints) {
for (var i = 0; i < tasks.length; i++) {
var task = tasks[i];

View File

@ -112,7 +112,23 @@
</label>
</div>
</div>
<!-- security -->
<!-- !security -->
<!-- edge -->
<div class="col-sm-12 form-section-title">
Edge
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="edge_checkin" class="col-sm-3 control-label text-left">
Edge agent poll frequency
<portainer-tooltip position="bottom" message="Specify the interval used by each Edge agent to checkin with the Portainer instance"></portainer-tooltip>
</label>
<div class="col-sm-9">
<select id="edge_checkin" class="form-control" ng-model="settings.EdgeAgentCheckinInterval" ng-options="+(opt.value) as opt.key for opt in state.availableEdgeAgentCheckinOptions"></select>
</div>
</div>
</div>
<!-- !edge -->
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">

View File

@ -3,7 +3,21 @@ angular.module('portainer.app')
function ($scope, $state, Notifications, SettingsService, StateManager) {
$scope.state = {
actionInProgress: false
actionInProgress: false,
availableEdgeAgentCheckinOptions: [
{
key: '5 seconds',
value: 5
},
{
key: '10 seconds',
value: 10
},
{
key: '30 seconds',
value: 30
},
]
};
$scope.formValues = {

View File

@ -158,14 +158,20 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe
function loadStack(id) {
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
EndpointService.endpoints()
.then(function success(data) {
$scope.endpoints = data.value;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
});
$q.all({
stack: StackService.stack(id),
endpoints: EndpointService.endpoints(),
groups: GroupService.groups()
})
.then(function success(data) {
var stack = data.stack;
$scope.endpoints = data.endpoints.value;
$scope.groups = data.groups;
$scope.stack = stack;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -185,7 +185,7 @@ function shell_buildBinaryOnDevOps(p, a) {
function shell_run() {
return [
'docker rm -f portainer',
'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer --no-analytics --template-file /app/templates.json'
'docker run -d -p 8000:8000 -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer --no-analytics --template-file /app/templates.json'
].join(';');
}

View File

@ -69,6 +69,7 @@
"splitargs": "github:deviantony/splitargs#semver:~0.2.0",
"toastr": "github:CodeSeven/toastr#semver:~2.1.3",
"ui-select": "^0.19.8",
"uuid": "^3.3.2",
"xterm": "^3.8.0"
},
"devDependencies": {