Merge branch 'release/1.17.0'

pull/1892/head 1.17.0
Anthony Lapenna 2018-05-10 17:22:20 +02:00
commit 19bb83ba2a
203 changed files with 4515 additions and 1399 deletions

View File

@ -17,10 +17,17 @@ steps:
- yarn grunt build-webapp
- mv api/cmd/portainer/portainer dist/
get_docker_version:
image: alpine
working_directory: ${{build_frontend}}
commands:
- cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2`
download_docker_binary:
image: busybox
working_directory: ${{build_frontend}}
commands:
- echo ${{DOCKER_VERSION}}
- wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz
- tar -xf /tmp/docker-binaries.tgz -C /tmp
- mv /tmp/docker/docker dist/

View File

@ -0,0 +1,46 @@
version: '1.0'
steps:
build_backend:
image: portainer/golang-builder:ci
working_directory: ${{main_clone}}
commands:
- mkdir -p /go/src/github.com/${{CF_REPO_OWNER}}
- ln -s /codefresh/volume/${{CF_REPO_NAME}}/api /go/src/github.com/${{CF_REPO_OWNER}}/${{CF_REPO_NAME}}
- /build.sh api/cmd/portainer
build_frontend:
image: portainer/angular-builder:latest
working_directory: ${{build_backend}}
commands:
- yarn
- yarn grunt build-webapp
- mv api/cmd/portainer/portainer dist/
get_docker_version:
image: alpine
working_directory: ${{build_frontend}}
commands:
- cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2`
download_docker_binary:
image: busybox
working_directory: ${{build_frontend}}
commands:
- echo ${{DOCKER_VERSION}}
- wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz
- tar -xf /tmp/docker-binaries.tgz -C /tmp
- mv /tmp/docker/docker dist/
build_image:
type: build
working_directory: ${{download_docker_binary}}
dockerfile: ./build/linux/Dockerfile
image_name: portainer/portainer
tag: ${{CF_BRANCH}}
push_image:
type: push
candidate: '${{build_image}}'
tag: 'pr${{CF_PULL_REQUEST_NUMBER}}'
registry: dockerhub

47
.github/ISSUE_TEMPLATE/Bug_report.md vendored Normal file
View File

@ -0,0 +1,47 @@
---
name: Bug report
about: Create a bug report
---
<!--
Thanks for reporting a bug for Portainer !
Do you need help or have a question? Come chat with us on Slack http://portainer.io/slack/ or gitter https://gitter.im/portainer/Lobby.
Before opening a new issue, make sure that we do not have any duplicates
already open. You can ensure this by searching the issue list for this
repository. If there is a duplicate, please close your issue and add a comment
to the existing issue instead.
Also, be sure to check our FAQ and documentation first: https://portainer.readthedocs.io
-->
**Bug description**
A clear and concise description of what the bug is.
**Expected behavior**
A clear and concise description of what you expected to happen.
Briefly describe what you were expecting.
**Steps to reproduce the issue:**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Technical details:**
* Portainer version:
* Docker version (managed by Portainer):
* Platform (windows/linux):
* Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`):
* Browser:
**Additional context**
Add any other context about the problem here.

15
.github/ISSUE_TEMPLATE/Custom.md vendored Normal file
View File

@ -0,0 +1,15 @@
---
name: Question
about: Ask us a question about Portainer usage or deployment
---
<!--
Do you need help or have a question? Come chat with us on Slack http://portainer.io/slack/ or gitter https://gitter.im/portainer/Lobby.
Also, be sure to check our FAQ and documentation first: https://portainer.readthedocs.io
-->
**Question**:
How can I deploy Portainer on... ?

View File

@ -0,0 +1,31 @@
---
name: Feature request
about: Suggest a feature/enhancement that should be added in Portainer
---
<!--
Thanks for opening a feature request for Portainer !
Do you need help or have a question? Come chat with us on Slack http://portainer.io/slack/ or gitter https://gitter.im/portainer/Lobby.
Before opening a new issue, make sure that we do not have any duplicates
already open. You can ensure this by searching the issue list for this
repository. If there is a duplicate, please close your issue and add a comment
to the existing issue instead.
Also, be sure to check our FAQ and documentation first: https://portainer.readthedocs.io
-->
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -20,6 +20,7 @@ type Store struct {
TeamService *TeamService
TeamMembershipService *TeamMembershipService
EndpointService *EndpointService
EndpointGroupService *EndpointGroupService
ResourceControlService *ResourceControlService
VersionService *VersionService
SettingsService *SettingsService
@ -38,6 +39,7 @@ const (
teamBucketName = "teams"
teamMembershipBucketName = "team_membership"
endpointBucketName = "endpoints"
endpointGroupBucketName = "endpoint_groups"
resourceControlBucketName = "resource_control"
settingsBucketName = "settings"
registryBucketName = "registries"
@ -53,6 +55,7 @@ func NewStore(storePath string) (*Store, error) {
TeamService: &TeamService{},
TeamMembershipService: &TeamMembershipService{},
EndpointService: &EndpointService{},
EndpointGroupService: &EndpointGroupService{},
ResourceControlService: &ResourceControlService{},
VersionService: &VersionService{},
SettingsService: &SettingsService{},
@ -64,6 +67,7 @@ func NewStore(storePath string) (*Store, error) {
store.TeamService.store = store
store.TeamMembershipService.store = store
store.EndpointService.store = store
store.EndpointGroupService.store = store
store.ResourceControlService.store = store
store.VersionService.store = store
store.SettingsService.store = store
@ -94,7 +98,7 @@ func (store *Store) Open() error {
store.db = db
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
registryBucketName, dockerhubBucketName, stackBucketName}
return db.Update(func(tx *bolt.Tx) error {
@ -110,6 +114,28 @@ func (store *Store) Open() error {
})
}
// Init creates the default data set.
func (store *Store) Init() error {
groups, err := store.EndpointGroupService.EndpointGroups()
if err != nil {
return err
}
if len(groups) == 0 {
unassignedGroup := &portainer.EndpointGroup{
Name: "Unassigned",
Description: "Unassigned endpoints",
Labels: []portainer.Pair{},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
return store.EndpointGroupService.CreateEndpointGroup(unassignedGroup)
}
return nil
}
// Close closes the BoltDB database.
func (store *Store) Close() error {
if store.db != nil {

View File

@ -0,0 +1,114 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// EndpointGroupService represents a service for managing endpoint groups.
type EndpointGroupService struct {
store *Store
}
// EndpointGroup returns an endpoint group by ID.
func (service *EndpointGroupService) EndpointGroup(ID portainer.EndpointGroupID) (*portainer.EndpointGroup, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
value := bucket.Get(internal.Itob(int(ID)))
if value == nil {
return portainer.ErrEndpointGroupNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var endpointGroup portainer.EndpointGroup
err = internal.UnmarshalEndpointGroup(data, &endpointGroup)
if err != nil {
return nil, err
}
return &endpointGroup, nil
}
// EndpointGroups return an array containing all the endpoint groups.
func (service *EndpointGroupService) EndpointGroups() ([]portainer.EndpointGroup, error) {
var endpointGroups = make([]portainer.EndpointGroup, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var endpointGroup portainer.EndpointGroup
err := internal.UnmarshalEndpointGroup(v, &endpointGroup)
if err != nil {
return err
}
endpointGroups = append(endpointGroups, endpointGroup)
}
return nil
})
if err != nil {
return nil, err
}
return endpointGroups, nil
}
// CreateEndpointGroup assign an ID to a new endpoint group and saves it.
func (service *EndpointGroupService) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
id, _ := bucket.NextSequence()
endpointGroup.ID = portainer.EndpointGroupID(id)
data, err := internal.MarshalEndpointGroup(endpointGroup)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(endpointGroup.ID)), data)
if err != nil {
return err
}
return nil
})
}
// UpdateEndpointGroup updates an endpoint group.
func (service *EndpointGroupService) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error {
data, err := internal.MarshalEndpointGroup(endpointGroup)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
err = bucket.Put(internal.Itob(int(ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteEndpointGroup deletes an endpoint group.
func (service *EndpointGroupService) DeleteEndpointGroup(ID portainer.EndpointGroupID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}
return nil
})
}

View File

@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
return json.Unmarshal(data, endpoint)
}
// MarshalEndpointGroup encodes an endpoint group to binary format.
func MarshalEndpointGroup(group *portainer.EndpointGroup) ([]byte, error) {
return json.Marshal(group)
}
// UnmarshalEndpointGroup decodes an endpoint group from a binary data.
func UnmarshalEndpointGroup(data []byte, group *portainer.EndpointGroup) error {
return json.Unmarshal(data, group)
}
// MarshalStack encodes a stack to binary format.
func MarshalStack(stack *portainer.Stack) ([]byte, error) {
return json.Marshal(stack)

View File

@ -0,0 +1,20 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateEndpointsToVersion9() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
endpoint.GroupID = portainer.EndpointGroupID(1)
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,20 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateEndpointsToVersion10() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
endpoint.Type = portainer.DockerEnvironment
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}

View File

@ -96,6 +96,22 @@ func (m *Migrator) Migrate() error {
}
}
// https: //github.com/portainer/portainer/issues/1396
if m.CurrentDBVersion < 9 {
err := m.updateEndpointsToVersion9()
if err != nil {
return err
}
}
// https://github.com/portainer/portainer/issues/461
if m.CurrentDBVersion < 10 {
err := m.updateEndpointsToVersion10()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err

View File

@ -38,6 +38,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(),
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(),
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),

View File

@ -9,6 +9,7 @@ const (
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem"

View File

@ -7,6 +7,7 @@ const (
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "C:\\certs\\ca.pem"
defaultTLSCertPath = "C:\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\certs\\key.pem"

View File

@ -10,6 +10,7 @@ import (
"github.com/portainer/portainer/filesystem"
"github.com/portainer/portainer/git"
"github.com/portainer/portainer/http"
"github.com/portainer/portainer/http/client"
"github.com/portainer/portainer/jwt"
"github.com/portainer/portainer/ldap"
@ -49,6 +50,11 @@ func initStore(dataStorePath string) *bolt.Store {
log.Fatal(err)
}
err = store.Init()
if err != nil {
log.Fatal(err)
}
err = store.MigrateData()
if err != nil {
log.Fatal(err)
@ -56,8 +62,8 @@ func initStore(dataStorePath string) *bolt.Store {
return store
}
func initStackManager(assetsPath string) portainer.StackManager {
return exec.NewStackManager(assetsPath)
func initStackManager(assetsPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) {
return exec.NewStackManager(assetsPath, signatureService, fileService)
}
func initJWTService(authenticationEnabled bool) portainer.JWTService {
@ -71,6 +77,10 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService {
return nil
}
func initDigitalSignatureService() portainer.DigitalSignatureService {
return &crypto.ECDSAService{}
}
func initCryptoService() portainer.CryptoService {
return &crypto.Service{}
}
@ -168,6 +178,35 @@ func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService
return &endpoints[0]
}
func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
private, public, err := fileService.LoadKeyPair()
if err != nil {
return err
}
return signatureService.ParseKeyPair(private, public)
}
func generateAndStoreKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
private, public, err := signatureService.GenerateKeyPair()
if err != nil {
return err
}
privateHeader, publicHeader := signatureService.PEMHeaders()
return fileService.StoreKeyPair(private, public, privateHeader, publicHeader)
}
func initKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
existingKeyPair, err := fileService.KeyPairFilesExist()
if err != nil {
log.Fatal(err)
}
if existingKeyPair {
return loadAndParseKeyPair(fileService, signatureService)
}
return generateAndStoreKeyPair(fileService, signatureService)
}
func main() {
flags := initCLI()
@ -176,19 +215,29 @@ func main() {
store := initStore(*flags.Data)
defer store.Close()
stackManager := initStackManager(*flags.Assets)
jwtService := initJWTService(!*flags.NoAuth)
cryptoService := initCryptoService()
digitalSignatureService := initDigitalSignatureService()
ldapService := initLDAPService()
gitService := initGitService()
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
err := initSettings(store.SettingsService, flags)
err := initKeyPair(fileService, digitalSignatureService)
if err != nil {
log.Fatal(err)
}
stackManager, err := initStackManager(*flags.Assets, digitalSignatureService, fileService)
if err != nil {
log.Fatal(err)
}
err = initSettings(store.SettingsService, flags)
if err != nil {
log.Fatal(err)
}
@ -209,9 +258,10 @@ func main() {
endpoint := &portainer.Endpoint{
Name: "primary",
URL: *flags.Endpoint,
Type: portainer.DockerEnvironment,
TLSConfig: portainer.TLSConfiguration{
TLS: *flags.TLSVerify,
TLSSkipVerify: false,
TLSSkipVerify: *flags.TLSSkipVerify,
TLSCACertPath: *flags.TLSCacert,
TLSCertPath: *flags.TLSCert,
TLSKeyPath: *flags.TLSKey,
@ -220,6 +270,16 @@ func main() {
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
}
agentOnDockerEnvironment, err := client.ExecutePingOperationFromEndpoint(endpoint)
if err != nil {
log.Fatal(err)
}
if agentOnDockerEnvironment {
endpoint.Type = portainer.AgentOnDockerEnvironment
}
err = store.EndpointService.CreateEndpoint(endpoint)
if err != nil {
log.Fatal(err)
@ -275,6 +335,7 @@ func main() {
TeamService: store.TeamService,
TeamMembershipService: store.TeamMembershipService,
EndpointService: store.EndpointService,
EndpointGroupService: store.EndpointGroupService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
RegistryService: store.RegistryService,
@ -286,6 +347,7 @@ func main() {
FileService: fileService,
LDAPService: ldapService,
GitService: gitService,
SignatureService: digitalSignatureService,
SSL: *flags.SSL,
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,

View File

@ -142,8 +142,6 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
if endpoint != nil {
job.logger.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL)
endpointsToUpdate = append(endpointsToUpdate, endpoint)
} else {
job.logger.Printf("No change detected for a stored endpoint. [name: %v] [url: %v]\n", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
}
} else {
job.logger.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL)

125
api/crypto/ecdsa.go Normal file
View File

@ -0,0 +1,125 @@
package crypto
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/md5"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"math/big"
)
const (
// PrivateKeyPemHeader represents the header that is appended to the PEM file when
// storing the private key.
PrivateKeyPemHeader = "EC PRIVATE KEY"
// PublicKeyPemHeader represents the header that is appended to the PEM file when
// storing the public key.
PublicKeyPemHeader = "ECDSA PUBLIC KEY"
)
// ECDSAService is a service used to create digital signatures when communicating with
// an agent based environment. It will automatically generates a key pair using ECDSA or
// can also reuse an existing ECDSA key pair.
type ECDSAService struct {
privateKey *ecdsa.PrivateKey
publicKey *ecdsa.PublicKey
encodedPubKey string
}
// EncodedPublicKey returns the encoded version of the public that can be used
// to be shared with other services. It's the hexadecimal encoding of the public key
// content.
func (service *ECDSAService) EncodedPublicKey() string {
return service.encodedPubKey
}
// PEMHeaders returns the ECDSA PEM headers.
func (service *ECDSAService) PEMHeaders() (string, string) {
return PrivateKeyPemHeader, PublicKeyPemHeader
}
// ParseKeyPair parses existing private/public key pair content and associate
// the parsed keys to the service.
func (service *ECDSAService) ParseKeyPair(private, public []byte) error {
privateKey, err := x509.ParseECPrivateKey(private)
if err != nil {
return err
}
service.privateKey = privateKey
encodedKey := hex.EncodeToString(public)
service.encodedPubKey = encodedKey
publicKey, err := x509.ParsePKIXPublicKey(public)
if err != nil {
return err
}
service.publicKey = publicKey.(*ecdsa.PublicKey)
return nil
}
// GenerateKeyPair will create a new key pair using ECDSA.
func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) {
pubkeyCurve := elliptic.P256()
privatekey, err := ecdsa.GenerateKey(pubkeyCurve, rand.Reader)
if err != nil {
return nil, nil, err
}
service.privateKey = privatekey
service.publicKey = &privatekey.PublicKey
private, err := x509.MarshalECPrivateKey(service.privateKey)
if err != nil {
return nil, nil, err
}
public, err := x509.MarshalPKIXPublicKey(service.publicKey)
if err != nil {
return nil, nil, err
}
encodedKey := hex.EncodeToString(public)
service.encodedPubKey = encodedKey
return private, public, nil
}
// Sign creates a signature from a message.
// It automatically hash the message using MD5 and creates a signature from
// that hash.
// It then encodes the generated signature in base64.
func (service *ECDSAService) Sign(message string) (string, error) {
digest := md5.New()
digest.Write([]byte(message))
hash := digest.Sum(nil)
r := big.NewInt(0)
s := big.NewInt(0)
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
if err != nil {
return "", err
}
keyBytes := service.privateKey.Params().BitSize / 8
rBytes := r.Bytes()
rBytesPadded := make([]byte, keyBytes)
copy(rBytesPadded[keyBytes-len(rBytes):], rBytes)
sBytes := s.Bytes()
sBytesPadded := make([]byte, keyBytes)
copy(sBytesPadded[keyBytes-len(sBytes):], sBytes)
signature := append(rBytesPadded, sBytesPadded...)
return base64.RawStdEncoding.EncodeToString(signature), nil
}

View File

@ -8,11 +8,32 @@ import (
"github.com/portainer/portainer"
)
func CreateTLSConfig(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) {
config := &tls.Config{}
config.InsecureSkipVerify = skipServerVerification
if !skipClientVerification {
certificate, err := tls.X509KeyPair(cert, key)
if err != nil {
return nil, err
}
config.Certificates = []tls.Certificate{certificate}
}
if !skipServerVerification {
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
config.RootCAs = caCertPool
}
return config, nil
}
// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key
func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) {
TLSConfig := &tls.Config{}
if config.TLSCertPath != "" && config.TLSKeyPath != "" {
if config.TLS && config.TLSCertPath != "" && config.TLSKeyPath != "" {
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
if err != nil {
return nil, err
@ -21,7 +42,7 @@ func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, er
TLSConfig.Certificates = []tls.Certificate{cert}
}
if !config.TLSSkipVerify {
if config.TLS && !config.TLSSkipVerify {
caCert, err := ioutil.ReadFile(config.TLSCACertPath)
if err != nil {
return nil, err

View File

@ -28,7 +28,7 @@ const (
// TeamMembership errors.
const (
ErrTeamMembershipNotFound = Error("Team membership not found")
ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team.")
ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team")
)
// ResourceControl errors.
@ -44,6 +44,12 @@ const (
ErrEndpointAccessDenied = Error("Access denied to endpoint")
)
// Endpoint group errors.
const (
ErrEndpointGroupNotFound = Error("Endpoint group not found")
ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group")
)
// Registry errors.
const (
ErrRegistryNotFound = Error("Registry not found")

View File

@ -12,14 +12,34 @@ import (
// StackManager represents a service for managing stacks.
type StackManager struct {
binaryPath string
binaryPath string
signatureService portainer.DigitalSignatureService
fileService portainer.FileService
}
type dockerCLIConfiguration struct {
HTTPHeaders struct {
ManagerOperationHeader string `json:"X-PortainerAgent-ManagerOperation"`
SignatureHeader string `json:"X-PortainerAgent-Signature"`
PublicKey string `json:"X-PortainerAgent-PublicKey"`
} `json:"HttpHeaders"`
}
// NewStackManager initializes a new StackManager service.
func NewStackManager(binaryPath string) *StackManager {
return &StackManager{
binaryPath: binaryPath,
// It also updates the configuration of the Docker CLI binary.
func NewStackManager(binaryPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) {
manager := &StackManager{
binaryPath: binaryPath,
signatureService: signatureService,
fileService: fileService,
}
err := manager.updateDockerCLIConfiguration(binaryPath)
if err != nil {
return nil, err
}
return manager, nil
}
// Login executes the docker login command against a list of registries (including DockerHub).
@ -61,7 +81,8 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint
env = append(env, envvar.Name+"="+envvar.Value)
}
return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
stackFolder := path.Dir(stackFilePath)
return runCommandAndCaptureStdErr(command, args, env, stackFolder)
}
// Remove executes the docker stack rm command.
@ -99,9 +120,12 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
}
args := make([]string, 0)
args = append(args, "--config", binaryPath)
args = append(args, "-H", endpoint.URL)
if endpoint.TLSConfig.TLS {
if !endpoint.TLSConfig.TLS && endpoint.TLSConfig.TLSSkipVerify {
args = append(args, "--tls")
} else if endpoint.TLSConfig.TLS {
args = append(args, "--tls")
if !endpoint.TLSConfig.TLSSkipVerify {
@ -115,3 +139,22 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
return command, args
}
func (manager *StackManager) updateDockerCLIConfiguration(binaryPath string) error {
config := dockerCLIConfiguration{}
config.HTTPHeaders.ManagerOperationHeader = "1"
signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
config.HTTPHeaders.SignatureHeader = signature
config.HTTPHeaders.PublicKey = manager.signatureService.EncodedPublicKey()
err = manager.fileService.WriteJSONToFile(path.Join(binaryPath, "config.json"), config)
if err != nil {
return err
}
return nil
}

View File

@ -2,6 +2,8 @@ package filesystem
import (
"bytes"
"encoding/json"
"encoding/pem"
"io/ioutil"
"github.com/portainer/portainer"
@ -26,6 +28,10 @@ const (
ComposeStorePath = "compose"
// ComposeFileDefaultName represents the default name of a compose file.
ComposeFileDefaultName = "docker-compose.yml"
// PrivateKeyFile represents the name on disk of the file containing the private key.
PrivateKeyFile = "portainer.key"
// PublicKeyFile represents the name on disk of the file containing the public key.
PublicKeyFile = "portainer.pub"
)
// Service represents a service for managing files and directories.
@ -198,6 +204,69 @@ func (service *Service) GetFileContent(filePath string) (string, error) {
return string(content), nil
}
// WriteJSONToFile writes JSON to the specified file.
func (service *Service) WriteJSONToFile(path string, content interface{}) error {
jsonContent, err := json.Marshal(content)
if err != nil {
return err
}
return ioutil.WriteFile(path, jsonContent, 0644)
}
// KeyPairFilesExist checks for the existence of the key files.
func (service *Service) KeyPairFilesExist() (bool, error) {
privateKeyPath := path.Join(service.dataStorePath, PrivateKeyFile)
exists, err := fileExists(privateKeyPath)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
publicKeyPath := path.Join(service.dataStorePath, PublicKeyFile)
exists, err = fileExists(publicKeyPath)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
return true, nil
}
// StoreKeyPair store the specified keys content as PEM files on disk.
func (service *Service) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error {
err := service.createPEMFileInStore(private, privatePEMHeader, PrivateKeyFile)
if err != nil {
return err
}
err = service.createPEMFileInStore(public, publicPEMHeader, PublicKeyFile)
if err != nil {
return err
}
return nil
}
// LoadKeyPair retrieve the content of both key files on disk.
func (service *Service) LoadKeyPair() ([]byte, []byte, error) {
privateKey, err := service.getContentFromPEMFile(PrivateKeyFile)
if err != nil {
return nil, nil, err
}
publicKey, err := service.getContentFromPEMFile(PublicKeyFile)
if err != nil {
return nil, nil, err
}
return privateKey, publicKey, nil
}
// createDirectoryInStore creates a new directory in the file store
func (service *Service) createDirectoryInStore(name string) error {
path := path.Join(service.fileStorePath, name)
@ -221,3 +290,43 @@ func (service *Service) createFileInStore(filePath string, r io.Reader) error {
return nil
}
func (service *Service) createPEMFileInStore(content []byte, fileType, filePath string) error {
path := path.Join(service.fileStorePath, filePath)
block := &pem.Block{Type: fileType, Bytes: content}
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer out.Close()
err = pem.Encode(out, block)
if err != nil {
return err
}
return nil
}
func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) {
path := path.Join(service.fileStorePath, filePath)
fileContent, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(fileContent)
return block.Bytes, nil
}
func fileExists(filePath string) (bool, error) {
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}

77
api/http/client/client.go Normal file
View File

@ -0,0 +1,77 @@
package client
import (
"crypto/tls"
"net/http"
"strings"
"time"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
)
// ExecutePingOperationFromEndpoint will send a SystemPing operation HTTP request to a Docker environment
// using the specified endpoint configuration. It is used exclusively when
// specifying an endpoint from the CLI via the -H flag.
func ExecutePingOperationFromEndpoint(endpoint *portainer.Endpoint) (bool, error) {
if strings.HasPrefix(endpoint.URL, "unix://") {
return false, nil
}
transport := &http.Transport{}
scheme := "http"
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
tlsConfig, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
if err != nil {
return false, err
}
scheme = "https"
transport.TLSClientConfig = tlsConfig
}
client := &http.Client{
Timeout: time.Second * 3,
Transport: transport,
}
target := strings.Replace(endpoint.URL, "tcp://", scheme+"://", 1)
return pingOperation(client, target)
}
// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment
// using the specified host and optional TLS configuration.
func ExecutePingOperation(host string, tlsConfig *tls.Config) (bool, error) {
transport := &http.Transport{}
scheme := "http"
if tlsConfig != nil {
transport.TLSClientConfig = tlsConfig
scheme = "https"
}
client := &http.Client{
Timeout: time.Second * 3,
Transport: transport,
}
target := strings.Replace(host, "tcp://", scheme+"://", 1)
return pingOperation(client, target)
}
func pingOperation(client *http.Client, target string) (bool, error) {
pingOperationURL := target + "/_ping"
response, err := client.Get(pingOperationURL)
if err != nil {
return false, err
}
agentOnDockerEnvironment := false
if response.Header.Get(portainer.PortainerAgentHeader) != "" {
agentOnDockerEnvironment = true
}
return agentOnDockerEnvironment, nil
}

View File

@ -17,6 +17,7 @@ func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.
logger.Printf("http error: %s (code=%d)", err, code)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()})
}

View File

@ -37,14 +37,14 @@ const (
)
// NewAuthHandler returns a new instance of AuthHandler.
func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHandler {
func NewAuthHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *AuthHandler {
h := &AuthHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
authDisabled: authDisabled,
}
h.Handle("/auth",
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))).Methods(http.MethodPost)
rateLimiter.LimitAccess(bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth)))).Methods(http.MethodPost)
return h
}

View File

@ -20,6 +20,7 @@ type DockerHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
TeamMembershipService portainer.TeamMembershipService
ProxyManager *proxy.Manager
}
@ -64,9 +65,17 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r
return
}
if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
if tokenData.Role != portainer.AdministratorRole {
group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
}
}
var proxy http.Handler

View File

@ -1,7 +1,12 @@
package handler
import (
"bytes"
"strings"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
"github.com/portainer/portainer/http/client"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
@ -22,6 +27,7 @@ type EndpointHandler struct {
Logger *log.Logger
authorizeEndpointManagement bool
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
FileService portainer.FileService
ProxyManager *proxy.Manager
}
@ -56,19 +62,6 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag
}
type (
postEndpointsRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
PublicURL string `valid:"-"`
TLS bool
TLSSkipVerify bool
TLSSkipClientVerify bool
}
postEndpointsResponse struct {
ID int `json:"Id"`
}
putEndpointAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
@ -78,10 +71,24 @@ type (
Name string `valid:"-"`
URL string `valid:"-"`
PublicURL string `valid:"-"`
GroupID int `valid:"-"`
TLS bool `valid:"-"`
TLSSkipVerify bool `valid:"-"`
TLSSkipClientVerify bool `valid:"-"`
}
postEndpointPayload struct {
name string
url string
publicURL string
groupID int
useTLS bool
skipTLSServerVerification bool
skipTLSClientVerification bool
caCert []byte
cert []byte
key []byte
}
)
// handleGetEndpoints handles GET requests on /endpoints
@ -98,7 +105,13 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
return
}
filteredEndpoints, err := security.FilterEndpoints(endpoints, securityContext)
groups, err := handler.EndpointGroupService.EndpointGroups()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredEndpoints, err := security.FilterEndpoints(endpoints, groups, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@ -107,6 +120,180 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
encodeJSON(w, filteredEndpoints, handler.Logger)
}
func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
tlsConfig, err := crypto.CreateTLSConfig(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification)
if err != nil {
return nil, err
}
agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, tlsConfig)
if err != nil {
return nil, err
}
endpointType := portainer.DockerEnvironment
if agentOnDockerEnvironment {
endpointType = portainer.AgentOnDockerEnvironment
}
endpoint := &portainer.Endpoint{
Name: payload.name,
URL: payload.url,
Type: endpointType,
GroupID: portainer.EndpointGroupID(payload.groupID),
PublicURL: payload.publicURL,
TLSConfig: portainer.TLSConfiguration{
TLS: payload.useTLS,
TLSSkipVerify: payload.skipTLSServerVerification,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
}
err = handler.EndpointService.CreateEndpoint(endpoint)
if err != nil {
return nil, err
}
folder := strconv.Itoa(int(endpoint.ID))
if !payload.skipTLSServerVerification {
r := bytes.NewReader(payload.caCert)
// TODO: review the API exposed by the FileService to store
// a file from a byte slice and return the path to the stored file instead
// of using multiple legacy calls (StoreTLSFile, GetPathForTLSFile) here.
err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCA, r)
if err != nil {
handler.EndpointService.DeleteEndpoint(endpoint.ID)
return nil, err
}
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSConfig.TLSCACertPath = caCertPath
}
if !payload.skipTLSClientVerification {
r := bytes.NewReader(payload.cert)
err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCert, r)
if err != nil {
handler.EndpointService.DeleteEndpoint(endpoint.ID)
return nil, err
}
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSCertPath = certPath
r = bytes.NewReader(payload.key)
err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileKey, r)
if err != nil {
handler.EndpointService.DeleteEndpoint(endpoint.ID)
return nil, err
}
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSConfig.TLSKeyPath = keyPath
}
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return nil, err
}
return endpoint, nil
}
func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
endpointType := portainer.DockerEnvironment
if !strings.HasPrefix(payload.url, "unix://") {
agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, nil)
if err != nil {
return nil, err
}
if agentOnDockerEnvironment {
endpointType = portainer.AgentOnDockerEnvironment
}
}
endpoint := &portainer.Endpoint{
Name: payload.name,
URL: payload.url,
Type: endpointType,
GroupID: portainer.EndpointGroupID(payload.groupID),
PublicURL: payload.publicURL,
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
}
err := handler.EndpointService.CreateEndpoint(endpoint)
if err != nil {
return nil, err
}
return endpoint, nil
}
func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
if payload.useTLS {
return handler.createTLSSecuredEndpoint(payload)
}
return handler.createUnsecuredEndpoint(payload)
}
func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload, error) {
payload := &postEndpointPayload{}
payload.name = r.FormValue("Name")
payload.url = r.FormValue("URL")
payload.publicURL = r.FormValue("PublicURL")
if payload.name == "" || payload.url == "" {
return nil, ErrInvalidRequestFormat
}
rawGroupID := r.FormValue("GroupID")
if rawGroupID == "" {
payload.groupID = 1
} else {
groupID, err := strconv.Atoi(rawGroupID)
if err != nil {
return nil, err
}
payload.groupID = groupID
}
payload.useTLS = r.FormValue("TLS") == "true"
if payload.useTLS {
payload.skipTLSServerVerification = r.FormValue("TLSSkipVerify") == "true"
payload.skipTLSClientVerification = r.FormValue("TLSSkipClientVerify") == "true"
if !payload.skipTLSServerVerification {
caCert, err := getUploadedFileContent(r, "TLSCACertFile")
if err != nil {
return nil, err
}
payload.caCert = caCert
}
if !payload.skipTLSClientVerification {
cert, err := getUploadedFileContent(r, "TLSCertFile")
if err != nil {
return nil, err
}
payload.cert = cert
key, err := getUploadedFileContent(r, "TLSKeyFile")
if err != nil {
return nil, err
}
payload.key = key
}
}
return payload, nil
}
// handlePostEndpoints handles POST requests on /endpoints
func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
@ -114,60 +301,19 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
return
}
var req postEndpointsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
payload, err := convertPostEndpointRequestToPayload(r)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpoint := &portainer.Endpoint{
Name: req.Name,
URL: req.URL,
PublicURL: req.PublicURL,
TLSConfig: portainer.TLSConfiguration{
TLS: req.TLS,
TLSSkipVerify: req.TLSSkipVerify,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
}
err = handler.EndpointService.CreateEndpoint(endpoint)
endpoint, err := handler.createEndpoint(payload)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.TLS {
folder := strconv.Itoa(int(endpoint.ID))
if !req.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSConfig.TLSCACertPath = caCertPath
}
if !req.TLSSkipClientVerify {
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSConfig.TLSKeyPath = keyPath
}
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger)
encodeJSON(w, &endpoint, handler.Logger)
}
// handleGetEndpoint handles GET requests on /endpoints/:id
@ -297,6 +443,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
endpoint.PublicURL = req.PublicURL
}
if req.GroupID != 0 {
endpoint.GroupID = portainer.EndpointGroupID(req.GroupID)
}
folder := strconv.Itoa(int(endpoint.ID))
if req.TLS {
endpoint.TLSConfig.TLS = true

View File

@ -0,0 +1,364 @@
package handler
import (
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"github.com/asaskevich/govalidator"
"github.com/gorilla/mux"
)
// EndpointGroupHandler represents an HTTP API handler for managing endpoint groups.
type EndpointGroupHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
}
// NewEndpointGroupHandler returns a new instance of EndpointGroupHandler.
func NewEndpointGroupHandler(bouncer *security.RequestBouncer) *EndpointGroupHandler {
h := &EndpointGroupHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/endpoint_groups",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpointGroups))).Methods(http.MethodPost)
h.Handle("/endpoint_groups",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpointGroups))).Methods(http.MethodGet)
h.Handle("/endpoint_groups/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpointGroup))).Methods(http.MethodGet)
h.Handle("/endpoint_groups/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroup))).Methods(http.MethodPut)
h.Handle("/endpoint_groups/{id}/access",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroupAccess))).Methods(http.MethodPut)
h.Handle("/endpoint_groups/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpointGroup))).Methods(http.MethodDelete)
return h
}
type (
postEndpointGroupsResponse struct {
ID int `json:"Id"`
}
postEndpointGroupsRequest struct {
Name string `valid:"required"`
Description string `valid:"-"`
Labels []portainer.Pair `valid:""`
AssociatedEndpoints []portainer.EndpointID `valid:""`
}
putEndpointGroupAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
putEndpointGroupsRequest struct {
Name string `valid:"-"`
Description string `valid:"-"`
Labels []portainer.Pair `valid:""`
AssociatedEndpoints []portainer.EndpointID `valid:""`
}
)
// handleGetEndpointGroups handles GET requests on /endpoint_groups
func (handler *EndpointGroupHandler) handleGetEndpointGroups(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpointGroups, err := handler.EndpointGroupService.EndpointGroups()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredEndpointGroups, err := security.FilterEndpointGroups(endpointGroups, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, filteredEndpointGroups, handler.Logger)
}
// handlePostEndpointGroups handles POST requests on /endpoint_groups
func (handler *EndpointGroupHandler) handlePostEndpointGroups(w http.ResponseWriter, r *http.Request) {
var req postEndpointGroupsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
endpointGroup := &portainer.EndpointGroup{
Name: req.Name,
Description: req.Description,
Labels: req.Labels,
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpoints, err := handler.EndpointService.Endpoints()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, endpoint := range endpoints {
if endpoint.GroupID == portainer.EndpointGroupID(1) {
err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, req.AssociatedEndpoints)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
encodeJSON(w, &postEndpointGroupsResponse{ID: int(endpointGroup.ID)}, handler.Logger)
}
// handleGetEndpointGroup handles GET requests on /endpoint_groups/:id
func (handler *EndpointGroupHandler) handleGetEndpointGroup(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID))
if err == portainer.ErrEndpointGroupNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, endpointGroup, handler.Logger)
}
// handlePutEndpointGroupAccess handles PUT requests on /endpoint_groups/:id/access
func (handler *EndpointGroupHandler) handlePutEndpointGroupAccess(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putEndpointGroupAccessRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID))
if err == portainer.ErrEndpointGroupNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.AuthorizedUsers != nil {
authorizedUserIDs := []portainer.UserID{}
for _, value := range req.AuthorizedUsers {
authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value))
}
endpointGroup.AuthorizedUsers = authorizedUserIDs
}
if req.AuthorizedTeams != nil {
authorizedTeamIDs := []portainer.TeamID{}
for _, value := range req.AuthorizedTeams {
authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value))
}
endpointGroup.AuthorizedTeams = authorizedTeamIDs
}
err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handlePutEndpointGroup handles PUT requests on /endpoint_groups/:id
func (handler *EndpointGroupHandler) handlePutEndpointGroup(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putEndpointGroupsRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
groupID := portainer.EndpointGroupID(endpointGroupID)
endpointGroup, err := handler.EndpointGroupService.EndpointGroup(groupID)
if err == portainer.ErrEndpointGroupNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.Name != "" {
endpointGroup.Name = req.Name
}
if req.Description != "" {
endpointGroup.Description = req.Description
}
endpointGroup.Labels = req.Labels
err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpoints, err := handler.EndpointService.Endpoints()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, endpoint := range endpoints {
err = handler.updateEndpointGroup(endpoint, groupID, req.AssociatedEndpoints)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
func (handler *EndpointGroupHandler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error {
if endpoint.GroupID == groupID {
return handler.checkForGroupUnassignment(endpoint, associatedEndpoints)
} else if endpoint.GroupID == portainer.EndpointGroupID(1) {
return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints)
}
return nil
}
func (handler *EndpointGroupHandler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error {
for _, id := range associatedEndpoints {
if id == endpoint.ID {
return nil
}
}
endpoint.GroupID = portainer.EndpointGroupID(1)
return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
}
func (handler *EndpointGroupHandler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error {
for _, id := range associatedEndpoints {
if id == endpoint.ID {
endpoint.GroupID = groupID
return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
}
}
return nil
}
// handleDeleteEndpointGroup handles DELETE requests on /endpoint_groups/:id
func (handler *EndpointGroupHandler) handleDeleteEndpointGroup(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
if endpointGroupID == 1 {
httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveDefaultGroup, http.StatusForbidden, handler.Logger)
return
}
groupID := portainer.EndpointGroupID(endpointGroupID)
_, err = handler.EndpointGroupService.EndpointGroup(groupID)
if err == portainer.ErrEndpointGroupNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpoints, err := handler.EndpointService.Endpoints()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, endpoint := range endpoints {
if endpoint.GroupID == groupID {
endpoint.GroupID = portainer.EndpointGroupID(1)
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
}

View File

@ -20,6 +20,7 @@ type StoridgeHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
TeamMembershipService portainer.TeamMembershipService
ProxyManager *proxy.Manager
}
@ -64,9 +65,17 @@ func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter
return
}
if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
if tokenData.Role != portainer.AdministratorRole {
group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
}
}
var storidgeExtension *portainer.EndpointExtension

View File

@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"io/ioutil"
"log"
"net/http"
"strings"
@ -18,6 +19,7 @@ type Handler struct {
TeamHandler *TeamHandler
TeamMembershipHandler *TeamMembershipHandler
EndpointHandler *EndpointHandler
EndpointGroupHandler *EndpointGroupHandler
RegistryHandler *RegistryHandler
DockerHubHandler *DockerHubHandler
ExtensionHandler *ExtensionHandler
@ -50,6 +52,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
switch {
case strings.Contains(r.URL.Path, "/docker/"):
@ -90,7 +94,24 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails.
func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger)
}
}
// getUploadedFileContent retrieve the content of a file uploaded in the request.
// Uses requestParameter as the key to retrieve the file in the request payload.
func getUploadedFileContent(request *http.Request, requestParameter string) ([]byte, error) {
file, _, err := request.FormFile(requestParameter)
if err != nil {
return nil, err
}
defer file.Close()
fileContent, err := ioutil.ReadAll(file)
if err != nil {
return nil, err
}
return fileContent, nil
}

View File

@ -1,11 +1,11 @@
package handler
import (
"bufio"
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
@ -16,119 +16,135 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/koding/websocketproxy"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
"golang.org/x/net/websocket"
httperror "github.com/portainer/portainer/http/error"
)
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
type WebSocketHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
}
type (
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
WebSocketHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
SignatureService portainer.DigitalSignatureService
connectionUpgrader websocket.Upgrader
}
webSocketExecRequestParams struct {
execID string
nodeName string
endpoint *portainer.Endpoint
}
execStartOperationPayload struct {
Tty bool
Detach bool
}
)
// NewWebSocketHandler returns a new instance of WebSocketHandler.
func NewWebSocketHandler() *WebSocketHandler {
h := &WebSocketHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
connectionUpgrader: websocket.Upgrader{},
}
h.Handle("/websocket/exec", websocket.Handler(h.webSocketDockerExec))
h.HandleFunc("/websocket/exec", h.handleWebsocketExec).Methods(http.MethodGet)
return h
}
func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) {
qry := ws.Request().URL.Query()
execID := qry.Get("id")
edpID := qry.Get("endpointId")
parsedID, err := strconv.Atoi(edpID)
if err != nil {
log.Printf("Unable to parse endpoint ID: %s", err)
// handleWebsocketExec handles GET requests on /websocket/exec?id=<execID>&endpointId=<endpointID>&nodeName=<nodeName>
// If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint.
// If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and
// an ExecStart operation HTTP request will be created and hijacked.
func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *http.Request) {
paramExecID := r.FormValue("id")
paramEndpointID := r.FormValue("endpointId")
if paramExecID == "" || paramEndpointID == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(parsedID)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
endpointID, err := strconv.Atoi(paramEndpointID)
if err != nil {
log.Printf("Unable to retrieve endpoint: %s", err)
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointURL, err := url.Parse(endpoint.URL)
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err != nil {
log.Printf("Unable to parse endpoint URL: %s", err)
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var host string
if endpointURL.Scheme == "tcp" {
host = endpointURL.Host
} else if endpointURL.Scheme == "unix" {
host = endpointURL.Path
params := &webSocketExecRequestParams{
endpoint: endpoint,
execID: paramExecID,
nodeName: r.FormValue("nodeName"),
}
// TODO: Should not be managed here
var tlsConfig *tls.Config
if endpoint.TLSConfig.TLS {
tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
if err != nil {
log.Fatalf("Unable to create TLS configuration: %s", err)
return
err = handler.handleRequest(w, r, params)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
if params.nodeName != "" {
return handler.proxyWebsocketRequest(w, r, params)
}
websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil)
if err != nil {
return err
}
defer websocketConn.Close()
return hijackExecStartOperation(websocketConn, params.endpoint, params.execID)
}
func (handler *WebSocketHandler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
agentURL, err := url.Parse(params.endpoint.URL)
if err != nil {
return err
}
agentURL.Scheme = "ws"
proxy := websocketproxy.NewProxy(agentURL)
if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify {
agentURL.Scheme = "wss"
proxy.Dialer = &websocket.Dialer{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: params.endpoint.TLSConfig.TLSSkipVerify,
},
}
}
if err := hijack(host, endpointURL.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil {
log.Fatalf("error during hijack: %s", err)
return
signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
proxy.Director = func(incoming *http.Request, out http.Header) {
out.Set(portainer.PortainerAgentSignatureHeader, signature)
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
}
r.Header.Del("Origin")
proxy.ServeHTTP(w, r)
return nil
}
type execConfig struct {
Tty bool
Detach bool
}
// hijack allows to upgrade an HTTP connection to a TCP connection
// It redirects IO streams for stdin, stdout and stderr to a websocket
func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerminal bool, in io.ReadCloser, stdout, stderr io.Writer, started chan io.Closer, data interface{}) error {
execConfig := &execConfig{
Tty: true,
Detach: false,
}
buf, err := json.Marshal(execConfig)
func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error {
dial, err := createDial(endpoint)
if err != nil {
return fmt.Errorf("error marshaling exec config: %s", err)
}
rdr := bytes.NewReader(buf)
req, err := http.NewRequest(method, path, rdr)
if err != nil {
return fmt.Errorf("error during hijack request: %s", err)
}
req.Header.Set("User-Agent", "Docker-Client")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "tcp")
req.Host = addr
var (
dial net.Conn
dialErr error
)
if tlsConfig == nil {
dial, dialErr = net.Dial(scheme, addr)
} else {
dial, dialErr = tls.Dial(scheme, addr, tlsConfig)
}
if dialErr != nil {
return dialErr
return err
}
// When we set up a TCP connection for hijack, there could be long periods
@ -140,57 +156,128 @@ func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerm
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second)
}
httpConn := httputil.NewClientConn(dial, nil)
defer httpConn.Close()
execStartRequest, err := createExecStartRequest(execID)
if err != nil {
return err
}
clientconn := httputil.NewClientConn(dial, nil)
defer clientconn.Close()
// Server hijacks the connection, error 'connection closed' expected
clientconn.Do(req)
rwc, br := clientconn.Hijack()
defer rwc.Close()
if started != nil {
started <- rwc
err = hijackRequest(websocketConn, httpConn, execStartRequest)
if err != nil {
return err
}
var receiveStdout chan error
if stdout != nil || stderr != nil {
go func() (err error) {
if setRawTerminal && stdout != nil {
_, err = io.Copy(stdout, br)
}
return err
}()
}
go func() error {
if in != nil {
io.Copy(rwc, in)
}
if conn, ok := rwc.(interface {
CloseWrite() error
}); ok {
if err := conn.CloseWrite(); err != nil {
}
}
return nil
}()
if stdout != nil || stderr != nil {
if err := <-receiveStdout; err != nil {
return err
}
}
go func() {
for {
fmt.Println(br)
}
}()
return nil
}
func createDial(endpoint *portainer.Endpoint) (net.Conn, error) {
url, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
var host string
if url.Scheme == "tcp" {
host = url.Host
} else if url.Scheme == "unix" {
host = url.Path
}
if endpoint.TLSConfig.TLS {
tlsConfig, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
if err != nil {
return nil, err
}
return tls.Dial(url.Scheme, host, tlsConfig)
}
return net.Dial(url.Scheme, host)
}
func createExecStartRequest(execID string) (*http.Request, error) {
execStartOperationPayload := &execStartOperationPayload{
Tty: true,
Detach: false,
}
encodedBody := bytes.NewBuffer(nil)
err := json.NewEncoder(encodedBody).Encode(execStartOperationPayload)
if err != nil {
return nil, err
}
request, err := http.NewRequest("POST", "/exec/"+execID+"/start", encodedBody)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Connection", "Upgrade")
request.Header.Set("Upgrade", "tcp")
return request, nil
}
func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error {
// Server hijacks the connection, error 'connection closed' expected
resp, err := httpConn.Do(request)
if err != httputil.ErrPersistEOF {
if err != nil {
return err
}
if resp.StatusCode != http.StatusSwitchingProtocols {
resp.Body.Close()
return fmt.Errorf("unable to upgrade to tcp, received %d", resp.StatusCode)
}
}
tcpConn, brw := httpConn.Hijack()
defer tcpConn.Close()
errorChan := make(chan error, 1)
go streamFromTCPConnToWebsocketConn(websocketConn, brw, errorChan)
go streamFromWebsocketConnToTCPConn(websocketConn, tcpConn, errorChan)
err = <-errorChan
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
return err
}
return nil
}
func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) {
for {
_, in, err := websocketConn.ReadMessage()
if err != nil {
errorChan <- err
break
}
_, err = tcpConn.Write(in)
if err != nil {
errorChan <- err
break
}
}
}
func streamFromTCPConnToWebsocketConn(websocketConn *websocket.Conn, br *bufio.Reader, errorChan chan error) {
for {
out := make([]byte, 1024)
_, err := br.Read(out)
if err != nil {
errorChan <- err
break
}
err = websocketConn.WriteMessage(websocket.TextMessage, out)
if err != nil {
errorChan <- err
break
}
}
}

View File

@ -98,9 +98,12 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team
}
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
metadata := make(map[string]interface{})
metadata["ResourceControl"] = resourceControl
object["Portainer"] = metadata
if object["Portainer"] == nil {
object["Portainer"] = make(map[string]interface{})
}
portainerMetadata := object["Portainer"].(map[string]interface{})
portainerMetadata["ResourceControl"] = resourceControl
return object
}

View File

@ -14,7 +14,7 @@ const (
// configListOperation extracts the response as a JSON object, loop through the configs array
// decorate and/or filter the configs based on resource controls before rewriting the response
func configListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func configListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// ConfigList response is a JSON array
@ -39,7 +39,7 @@ func configListOperation(request *http.Request, response *http.Response, executo
// configInspectOperation extracts the response as a JSON object, verify that the user
// has access to the config based on resource control (check are done based on the configID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated config.
func configInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func configInspectOperation(response *http.Response, executor *operationExecutor) error {
// ConfigInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
responseObject, err := getResponseAsJSONOBject(response)

View File

@ -16,7 +16,7 @@ const (
// containerListOperation extracts the response as a JSON object, loop through the containers array
// decorate and/or filter the containers based on resource controls before rewriting the response
func containerListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func containerListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// ContainerList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
@ -47,7 +47,7 @@ func containerListOperation(request *http.Request, response *http.Response, exec
// containerInspectOperation extracts the response as a JSON object, verify that the user
// has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated container.
func containerInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func containerInspectOperation(response *http.Response, executor *operationExecutor) error {
// ContainerInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
responseObject, err := getResponseAsJSONOBject(response)

View File

@ -17,6 +17,7 @@ type proxyFactory struct {
SettingsService portainer.SettingsService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
SignatureService portainer.DigitalSignatureService
}
func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler {
@ -24,10 +25,11 @@ func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler {
return newSingleHostReverseProxyWithHostHeader(u)
}
func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool) (http.Handler, error) {
u.Scheme = "https"
proxy := factory.createDockerReverseProxy(u)
config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
proxy := factory.createDockerReverseProxy(u, enableSignature)
config, err := crypto.CreateTLSConfiguration(tlsConfig)
if err != nil {
return nil, err
}
@ -36,14 +38,15 @@ func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer
return proxy, nil
}
func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL) http.Handler {
func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, enableSignature bool) http.Handler {
u.Scheme = "http"
return factory.createDockerReverseProxy(u)
return factory.createDockerReverseProxy(u, enableSignature)
}
func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
proxy := &socketProxy{}
transport := &proxyTransport{
enableSignature: false,
ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService,
@ -55,9 +58,10 @@ func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
return proxy
}
func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.ReverseProxy {
func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignature bool) *httputil.ReverseProxy {
proxy := newSingleHostReverseProxyWithHostHeader(u)
transport := &proxyTransport{
enableSignature: enableSignature,
ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService,
@ -65,6 +69,11 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.Reve
DockerHubService: factory.DockerHubService,
dockerTransport: &http.Transport{},
}
if enableSignature {
transport.SignatureService = factory.SignatureService
}
proxy.Transport = transport
return proxy
}

View File

@ -9,24 +9,37 @@ import (
"github.com/portainer/portainer"
)
// Manager represents a service used to manage Docker proxies.
type Manager struct {
proxyFactory *proxyFactory
proxies cmap.ConcurrentMap
extensionProxies cmap.ConcurrentMap
}
type (
// Manager represents a service used to manage Docker proxies.
Manager struct {
proxyFactory *proxyFactory
proxies cmap.ConcurrentMap
extensionProxies cmap.ConcurrentMap
}
// ManagerParams represents the required parameters to create a new Manager instance.
ManagerParams struct {
ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService
SettingsService portainer.SettingsService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
SignatureService portainer.DigitalSignatureService
}
)
// NewManager initializes a new proxy Service
func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService, registryService portainer.RegistryService, dockerHubService portainer.DockerHubService) *Manager {
func NewManager(parameters *ManagerParams) *Manager {
return &Manager{
proxies: cmap.New(),
extensionProxies: cmap.New(),
proxyFactory: &proxyFactory{
ResourceControlService: resourceControlService,
TeamMembershipService: teamMembershipService,
SettingsService: settingsService,
RegistryService: registryService,
DockerHubService: dockerHubService,
ResourceControlService: parameters.ResourceControlService,
TeamMembershipService: parameters.TeamMembershipService,
SettingsService: parameters.SettingsService,
RegistryService: parameters.RegistryService,
DockerHubService: parameters.DockerHubService,
SignatureService: parameters.SignatureService,
},
}
}
@ -41,14 +54,19 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht
return nil, err
}
enableSignature := false
if endpoint.Type == portainer.AgentOnDockerEnvironment {
enableSignature = true
}
if endpointURL.Scheme == "tcp" {
if endpoint.TLSConfig.TLS {
proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, endpoint)
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature)
if err != nil {
return nil, err
}
} else {
proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL)
proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL, enableSignature)
}
} else {
// Assume unix:// scheme

View File

@ -15,7 +15,7 @@ const (
// networkListOperation extracts the response as a JSON object, loop through the networks array
// decorate and/or filter the networks based on resource controls before rewriting the response
func networkListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func networkListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// NetworkList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
@ -39,7 +39,7 @@ func networkListOperation(request *http.Request, response *http.Response, execut
// networkInspectOperation extracts the response as a JSON object, verify that the user
// has access to the network based on resource control and either rewrite an access denied response
// or a decorated network.
func networkInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func networkInspectOperation(response *http.Response, executor *operationExecutor) error {
// NetworkInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
responseObject, err := getResponseAsJSONOBject(response)

View File

@ -13,6 +13,8 @@ import (
const (
// ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse
ErrEmptyResponseBody = portainer.Error("Empty response body")
// ErrInvalidResponseContent defines an error raised when Portainer excepts a JSON array and get something else.
ErrInvalidResponseContent = portainer.Error("Invalid Docker response")
)
func extractJSONField(jsonObject map[string]interface{}, key string) map[string]interface{} {
@ -39,8 +41,17 @@ func getResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
return nil, err
}
responseObject := responseData.([]interface{})
return responseObject, nil
switch responseObject := responseData.(type) {
case []interface{}:
return responseObject, nil
case map[string]interface{}:
if responseObject["message"] != nil {
return nil, portainer.Error(responseObject["message"].(string))
}
return nil, ErrInvalidResponseContent
default:
return nil, ErrInvalidResponseContent
}
}
func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) {

View File

@ -14,7 +14,7 @@ const (
// secretListOperation extracts the response as a JSON object, loop through the secrets array
// decorate and/or filter the secrets based on resource controls before rewriting the response
func secretListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func secretListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// SecretList response is a JSON array
@ -39,7 +39,7 @@ func secretListOperation(request *http.Request, response *http.Response, executo
// secretInspectOperation extracts the response as a JSON object, verify that the user
// has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated secret.
func secretInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func secretInspectOperation(response *http.Response, executor *operationExecutor) error {
// SecretInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
responseObject, err := getResponseAsJSONOBject(response)

View File

@ -15,7 +15,7 @@ const (
// serviceListOperation extracts the response as a JSON array, loop through the service array
// decorate and/or filter the services based on resource controls before rewriting the response
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func serviceListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// ServiceList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
@ -39,7 +39,7 @@ func serviceListOperation(request *http.Request, response *http.Response, execut
// serviceInspectOperation extracts the response as a JSON object, verify that the user
// has access to the service based on resource control and either rewrite an access denied response
// or a decorated service.
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func serviceInspectOperation(response *http.Response, executor *operationExecutor) error {
// ServiceInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
responseObject, err := getResponseAsJSONOBject(response)

View File

@ -15,7 +15,7 @@ const (
// taskListOperation extracts the response as a JSON object, loop through the tasks array
// and filter the tasks based on resource controls before rewriting the response
func taskListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func taskListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// TaskList response is a JSON array

View File

@ -5,20 +5,25 @@ import (
"encoding/json"
"net/http"
"path"
"regexp"
"strings"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/security"
)
var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`)
type (
proxyTransport struct {
dockerTransport *http.Transport
enableSignature bool
ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
SettingsService portainer.SettingsService
SignatureService portainer.DigitalSignatureService
}
restrictedOperationContext struct {
isAdmin bool
@ -42,7 +47,7 @@ type (
operationContext *restrictedOperationContext
labelBlackList []portainer.Pair
}
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
restrictedOperationRequest func(*http.Response, *operationExecutor) error
operationRequest func(*http.Request) error
)
@ -55,7 +60,18 @@ func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Resp
}
func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) {
path := request.URL.Path
path := apiVersionRe.ReplaceAllString(request.URL.Path, "")
request.URL.Path = path
if p.enableSignature {
signature, err := p.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, p.SignatureService.EncodedPublicKey())
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
}
switch {
case strings.HasPrefix(path, "/configs"):
@ -258,7 +274,7 @@ func (p *proxyTransport) proxyImageRequest(request *http.Request) (*http.Respons
case "/images/create":
return p.replaceRegistryAuthenticationHeader(request)
default:
if match, _ := path.Match("/images/*/push", requestPath); match {
if path.Base(requestPath) == "push" && request.Method == http.MethodPost {
return p.replaceRegistryAuthenticationHeader(request)
}
return p.executeDockerRequest(request)
@ -388,7 +404,7 @@ func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request,
return response, err
}
err = operation(request, response, executor)
err = operation(response, executor)
return response, err
}

View File

@ -15,7 +15,7 @@ const (
// volumeListOperation extracts the response as a JSON object, loop through the volume array
// decorate and/or filter the volumes based on resource controls before rewriting the response
func volumeListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func volumeListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// VolumeList response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
@ -48,7 +48,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo
// volumeInspectOperation extracts the response as a JSON object, verify that the user
// has access to the volume based on any existing resource control and either rewrite an access denied response
// or a decorated volume.
func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func volumeInspectOperation(response *http.Response, executor *operationExecutor) error {
// VolumeInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
responseObject, err := getResponseAsJSONOBject(response)

View File

@ -124,34 +124,37 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques
// AuthorizedEndpointAccess ensure that the user can access the specified endpoint.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams of the endpoint and the associated group.
func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
groupAccess := authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
if !groupAccess {
return authorizedAccess(userID, memberships, endpoint.AuthorizedUsers, endpoint.AuthorizedTeams)
}
return true
}
// AuthorizedEndpointGroupAccess ensure that the user can access the specified endpoint group.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams.
func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
for _, authorizedUserID := range endpoint.AuthorizedUsers {
if authorizedUserID == userID {
return true
}
}
for _, membership := range memberships {
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
if membership.TeamID == authorizedTeamID {
return true
}
}
}
return false
func AuthorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
}
// AuthorizedRegistryAccess ensure that the user can access the specified registry.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams.
func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
for _, authorizedUserID := range registry.AuthorizedUsers {
return authorizedAccess(userID, memberships, registry.AuthorizedUsers, registry.AuthorizedTeams)
}
func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, authorizedUsers []portainer.UserID, authorizedTeams []portainer.TeamID) bool {
for _, authorizedUserID := range authorizedUsers {
if authorizedUserID == userID {
return true
}
}
for _, membership := range memberships {
for _, authorizedTeamID := range registry.AuthorizedTeams {
for _, authorizedTeamID := range authorizedTeams {
if membership.TeamID == authorizedTeamID {
return true
}

View File

@ -79,15 +79,17 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
}
// FilterEndpoints filters endpoints based on user role and team memberships.
// Non administrator users only have access to authorized endpoints.
func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups).
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
filteredEndpoints := endpoints
if !context.IsAdmin {
filteredEndpoints = make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if AuthorizedEndpointAccess(&endpoint, context.UserID, context.UserMemberships) {
endpointGroup := getAssociatedGroup(&endpoint, groups)
if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
@ -95,3 +97,30 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC
return filteredEndpoints, nil
}
// FilterEndpointGroups filters endpoint groups based on user role and team memberships.
// Non administrator users only have access to authorized endpoint groups.
func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.EndpointGroup, error) {
filteredEndpointGroups := endpointGroups
if !context.IsAdmin {
filteredEndpointGroups = make([]portainer.EndpointGroup, 0)
for _, group := range endpointGroups {
if AuthorizedEndpointGroupAccess(&group, context.UserID, context.UserMemberships) {
filteredEndpointGroups = append(filteredEndpointGroups, group)
}
}
}
return filteredEndpointGroups, nil
}
func getAssociatedGroup(endpoint *portainer.Endpoint, groups []portainer.EndpointGroup) *portainer.EndpointGroup {
for _, group := range groups {
if group.ID == endpoint.GroupID {
return &group
}
}
return nil
}

View File

@ -0,0 +1,47 @@
package security
import (
"net/http"
"strings"
"time"
"github.com/g07cha/defender"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
)
// RateLimiter represents an entity that manages request rate limiting
type RateLimiter struct {
*defender.Defender
}
// NewRateLimiter initializes a new RateLimiter
func NewRateLimiter(maxRequests int, duration time.Duration, banDuration time.Duration) *RateLimiter {
messages := make(chan struct{})
limiter := defender.New(maxRequests, duration, banDuration)
go limiter.CleanupTask(messages)
return &RateLimiter{
limiter,
}
}
// LimitAccess wraps current request with check if remote address does not goes above the defined limits
func (limiter *RateLimiter) LimitAccess(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := StripAddrPort(r.RemoteAddr)
if banned := limiter.Inc(ip); banned == true {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
return
}
next.ServeHTTP(w, r)
})
}
// StripAddrPort removes port from IP address
func StripAddrPort(addr string) string {
portIndex := strings.LastIndex(addr, ":")
if portIndex != -1 {
addr = addr[:portIndex]
}
return addr
}

View File

@ -0,0 +1,69 @@
package security
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestLimitAccess(t *testing.T) {
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
t.Run("Request below the limit", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
rateLimiter := NewRateLimiter(10, 1*time.Second, 1*time.Hour)
handler := rateLimiter.LimitAccess(testHandler)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
})
t.Run("Request above the limit", func(t *testing.T) {
rateLimiter := NewRateLimiter(1, 1*time.Second, 1*time.Hour)
handler := rateLimiter.LimitAccess(testHandler)
ts := httptest.NewServer(handler)
defer ts.Close()
http.Get(ts.URL)
resp, err := http.Get(ts.URL)
if err != nil {
t.Fatal(err)
}
if status := resp.StatusCode; status != http.StatusForbidden {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusForbidden)
}
})
}
func TestStripAddrPort(t *testing.T) {
t.Run("IP with port", func(t *testing.T) {
result := StripAddrPort("127.0.0.1:1000")
if result != "127.0.0.1" {
t.Errorf("Expected IP with address to be '127.0.0.1', but it was %s instead", result)
}
})
t.Run("IP without port", func(t *testing.T) {
result := StripAddrPort("127.0.0.1")
if result != "127.0.0.1" {
t.Errorf("Expected IP with address to be '127.0.0.1', but it was %s instead", result)
}
})
t.Run("Local IP", func(t *testing.T) {
result := StripAddrPort("[::1]:1000")
if result != "[::1]" {
t.Errorf("Expected IP with address to be '[::1]', but it was %s instead", result)
}
})
}

View File

@ -1,6 +1,8 @@
package http
import (
"time"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/handler"
"github.com/portainer/portainer/http/handler/extensions"
@ -22,6 +24,7 @@ type Server struct {
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
ResourceControlService portainer.ResourceControlService
SettingsService portainer.SettingsService
CryptoService portainer.CryptoService
@ -33,6 +36,7 @@ type Server struct {
StackManager portainer.StackManager
LDAPService portainer.LDAPService
GitService portainer.GitService
SignatureService portainer.DigitalSignatureService
Handler *handler.Handler
SSL bool
SSLCert string
@ -42,10 +46,19 @@ type Server struct {
// Start starts the HTTP server
func (server *Server) Start() error {
requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled)
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService, server.RegistryService, server.DockerHubService)
proxyManagerParameters := &proxy.ManagerParams{
ResourceControlService: server.ResourceControlService,
TeamMembershipService: server.TeamMembershipService,
SettingsService: server.SettingsService,
RegistryService: server.RegistryService,
DockerHubService: server.DockerHubService,
SignatureService: server.SignatureService,
}
proxyManager := proxy.NewManager(proxyManagerParameters)
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public"))
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
var authHandler = handler.NewAuthHandler(requestBouncer, rateLimiter, server.AuthDisabled)
authHandler.UserService = server.UserService
authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService
@ -72,14 +85,20 @@ func (server *Server) Start() error {
templatesHandler.SettingsService = server.SettingsService
var dockerHandler = handler.NewDockerHandler(requestBouncer)
dockerHandler.EndpointService = server.EndpointService
dockerHandler.EndpointGroupService = server.EndpointGroupService
dockerHandler.TeamMembershipService = server.TeamMembershipService
dockerHandler.ProxyManager = proxyManager
var websocketHandler = handler.NewWebSocketHandler()
websocketHandler.EndpointService = server.EndpointService
websocketHandler.SignatureService = server.SignatureService
var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement)
endpointHandler.EndpointService = server.EndpointService
endpointHandler.EndpointGroupService = server.EndpointGroupService
endpointHandler.FileService = server.FileService
endpointHandler.ProxyManager = proxyManager
var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer)
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
endpointGroupHandler.EndpointService = server.EndpointService
var registryHandler = handler.NewRegistryHandler(requestBouncer)
registryHandler.RegistryService = server.RegistryService
var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer)
@ -102,6 +121,7 @@ func (server *Server) Start() error {
extensionHandler.ProxyManager = proxyManager
var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer)
storidgeHandler.EndpointService = server.EndpointService
storidgeHandler.EndpointGroupService = server.EndpointGroupService
storidgeHandler.TeamMembershipService = server.TeamMembershipService
storidgeHandler.ProxyManager = proxyManager
@ -111,6 +131,7 @@ func (server *Server) Start() error {
TeamHandler: teamHandler,
TeamMembershipHandler: teamMembershipHandler,
EndpointHandler: endpointHandler,
EndpointGroupHandler: endpointGroupHandler,
RegistryHandler: registryHandler,
DockerHubHandler: dockerHubHandler,
ResourceHandler: resourceHandler,

View File

@ -24,6 +24,7 @@ type (
NoAnalytics *bool
Templates *string
TLSVerify *bool
TLSSkipVerify *bool
TLSCacert *string
TLSCert *string
TLSKey *string
@ -168,12 +169,17 @@ type (
// EndpointID represents an endpoint identifier.
EndpointID int
// EndpointType represents the type of an endpoint.
EndpointType int
// Endpoint represents a Docker endpoint with all the info required
// to connect to it.
Endpoint struct {
ID EndpointID `json:"Id"`
Name string `json:"Name"`
Type EndpointType `json:"Type"`
URL string `json:"URL"`
GroupID EndpointGroupID `json:"GroupId"`
PublicURL string `json:"PublicURL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
@ -188,6 +194,19 @@ type (
TLSKeyPath string `json:"TLSKey,omitempty"`
}
// EndpointGroupID represents an endpoint group identifier.
EndpointGroupID int
// EndpointGroup represents a group of endpoints.
EndpointGroup struct {
ID EndpointGroupID `json:"Id"`
Name string `json:"Name"`
Description string `json:"Description"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
Labels []Pair `json:"Labels"`
}
// EndpointExtension represents a extension associated to an endpoint.
EndpointExtension struct {
Type EndpointExtensionType `json:"Type"`
@ -248,6 +267,7 @@ type (
// DataStore defines the interface to manage the data.
DataStore interface {
Open() error
Init() error
Close() error
MigrateData() error
}
@ -301,6 +321,15 @@ type (
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
}
// EndpointGroupService represents a service for managing endpoint group data.
EndpointGroupService interface {
EndpointGroup(ID EndpointGroupID) (*EndpointGroup, error)
EndpointGroups() ([]EndpointGroup, error)
CreateEndpointGroup(group *EndpointGroup) error
UpdateEndpointGroup(ID EndpointGroupID, group *EndpointGroup) error
DeleteEndpointGroup(ID EndpointGroupID) error
}
// RegistryService represents a service for managing registry data.
RegistryService interface {
Registry(ID RegistryID) (*Registry, error)
@ -354,6 +383,15 @@ type (
CompareHashAndData(hash string, data string) error
}
// DigitalSignatureService represents a service to manage digital signatures.
DigitalSignatureService interface {
ParseKeyPair(private, public []byte) error
GenerateKeyPair() ([]byte, []byte, error)
EncodedPublicKey() string
PEMHeaders() (string, string)
Sign(message string) (string, error)
}
// JWTService represents a service for managing JWT tokens.
JWTService interface {
GenerateToken(data *TokenData) (string, error)
@ -371,6 +409,10 @@ type (
GetStackProjectPath(stackIdentifier string) string
StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error)
StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error)
KeyPairFilesExist() (bool, error)
StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error
LoadKeyPair() ([]byte, []byte, error)
WriteJSONToFile(path string, content interface{}) error
}
// GitService represents a service for managing Git.
@ -401,11 +443,22 @@ type (
const (
// APIVersion is the version number of the Portainer API.
APIVersion = "1.16.5"
APIVersion = "1.17.0"
// DBVersion is the version number of the Portainer database.
DBVersion = 8
DBVersion = 10
// DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
// PortainerAgentHeader represents the name of the header available in any agent response
PortainerAgentHeader = "Portainer-Agent"
// 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
PortainerAgentSignatureHeader = "X-PortainerAgent-Signature"
// PortainerAgentPublicKeyHeader represent the name of the header containing the public key
PortainerAgentPublicKeyHeader = "X-PortainerAgent-PublicKey"
// PortainerAgentSignatureMessage represents the message used to create a digital signature
// to be used when communicating with an agent
PortainerAgentSignatureMessage = "Portainer-App"
)
const (
@ -470,3 +523,11 @@ const (
// StoridgeEndpointExtension represents the Storidge extension
StoridgeEndpointExtension
)
const (
_ EndpointType = iota
// DockerEnvironment represents an endpoint connected to a Docker environment
DockerEnvironment
// AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment
AgentOnDockerEnvironment
)

View File

@ -56,7 +56,7 @@ info:
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
version: "1.16.5"
version: "1.17.0"
title: "Portainer API"
contact:
email: "info@portainer.io"
@ -224,7 +224,7 @@ paths:
**Access policy**: administrator
operationId: "EndpointCreate"
consumes:
- "application/json"
- "multipart/form-data"
produces:
- "application/json"
parameters:
@ -238,7 +238,7 @@ paths:
200:
description: "Success"
schema:
$ref: "#/definitions/EndpointCreateResponse"
$ref: "#/definitions/Endpoint"
400:
description: "Invalid request"
schema:
@ -2143,7 +2143,7 @@ definitions:
description: "Is analytics enabled"
Version:
type: "string"
example: "1.16.5"
example: "1.17.0"
description: "Portainer API version"
PublicSettingsInspectResponse:
type: "object"
@ -2455,6 +2455,15 @@ definitions:
type: "boolean"
example: false
description: "Skip client verification when using TLS"
TLSCACertFile:
type: "file"
description: "TLS CA certificate file"
TLSCertFile:
type: "file"
description: "TLS client certificate file"
TLSKeyFile:
type: "file"
description: "TLS client key file"
EndpointCreateResponse:
type: "object"
properties:
@ -2603,12 +2612,13 @@ definitions:
ResourceID:
type: "string"
example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08"
description: "Docker resource identifier on which access control will be applied"
description: "Docker resource identifier on which access control will be applied.\
\ In the case of a resource control applied to a stack, use the stack name as identifier"
Type:
type: "string"
example: "container"
description: "Type of Docker resource. Valid values are: container, volume\
\ or service"
\ service, secret, config or stack"
AdministratorsOnly:
type: "boolean"
example: true
@ -2923,7 +2933,7 @@ definitions:
RepositoryPassword:
type: "string"
example: "myGitPassword"
description: "Password used in basic authentication. Required when RepositoryAuthentication is true."
description: "Password used in basic authentication. Required when RepositoryAuthentication is true."
Env:
type: "array"
description: "A list of environment variables used during stack deployment"

View File

@ -17,6 +17,7 @@ angular.module('portainer', [
'luegg.directives',
'portainer.templates',
'portainer.app',
'portainer.agent',
'portainer.docker',
'extension.storidge',
'rzModule']);

1
app/agent/_module.js Normal file
View File

@ -0,0 +1 @@
angular.module('portainer.agent', []);

View File

@ -0,0 +1,7 @@
angular.module('portainer.agent').component('nodeSelector', {
templateUrl: 'app/agent/components/node-selector/nodeSelector.html',
controller: 'NodeSelectorController',
bindings: {
model: '='
}
});

View File

@ -0,0 +1,8 @@
<div class="form-group">
<label for="target_node" class="col-sm-1 control-label text-left">Node</label>
<div class="col-sm-11">
<select class="form-control"
ng-model="$ctrl.model" ng-options="agent.NodeName as agent.NodeName for agent in $ctrl.agents"
></select>
</div>
</div>

View File

@ -0,0 +1,18 @@
angular.module('portainer.agent')
.controller('NodeSelectorController', ['AgentService', 'Notifications', function (AgentService, Notifications) {
var ctrl = this;
this.$onInit = function() {
AgentService.agents()
.then(function success(data) {
ctrl.agents = data;
if (!ctrl.model) {
ctrl.model = data[0].NodeName;
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load agents');
});
};
}]);

View File

@ -0,0 +1,5 @@
function AgentViewModel(data) {
this.IPAddress = data.IPAddress;
this.NodeName = data.NodeName;
this.NodeRole = data.NodeRole;
}

10
app/agent/rest/agent.js Normal file
View File

@ -0,0 +1,10 @@
angular.module('portainer.agent')
.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', {
endpointId: EndpointProvider.endpointID
},
{
query: {method: 'GET', isArray: true}
});
}]);

View File

@ -0,0 +1,24 @@
angular.module('portainer.agent')
.factory('AgentService', ['$q', 'Agent', function AgentServiceFactory($q, Agent) {
'use strict';
var service = {};
service.agents = function() {
var deferred = $q.defer();
Agent.query({}).$promise
.then(function success(data) {
var agents = data.map(function (item) {
return new AgentViewModel(item);
});
deferred.resolve(agents);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve agents', err: err });
});
return deferred.promise;
};
return service;
}]);

View File

@ -1,5 +1,6 @@
angular.module('portainer')
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar) {
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper',
function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar, $transitions, HttpRequestHelper) {
'use strict';
EndpointProvider.initialize();
@ -27,6 +28,10 @@ angular.module('portainer')
originalSet.apply(cfpLoadingBar, arguments);
}
};
$transitions.onBefore({ to: 'docker.**' }, function() {
HttpRequestHelper.resetAgentTargetQueue();
});
}]);

View File

@ -20,6 +20,20 @@ angular.module('portainer')
}]
});
$httpProvider.interceptors.push('jwtInterceptor');
$httpProvider.defaults.headers.post['Content-Type'] = 'application/json';
$httpProvider.defaults.headers.put['Content-Type'] = 'application/json';
$httpProvider.defaults.headers.patch['Content-Type'] = 'application/json';
$httpProvider.interceptors.push(['HttpRequestHelper', function(HttpRequestHelper) {
return {
request: function(config) {
if (config.url.indexOf('/docker/') > -1) {
config.headers['X-PortainerAgent-Target'] = HttpRequestHelper.portainerAgentTargetHeader();
}
return config;
}
};
}]);
AnalyticsProvider.setAccount('@@CONFIG_GA_ID');
AnalyticsProvider.startOffline(true);

View File

@ -2,6 +2,7 @@ angular.module('portainer')
.constant('API_ENDPOINT_AUTH', 'api/auth')
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
.constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups')
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')

View File

@ -57,7 +57,7 @@ angular.module('portainer.docker', ['portainer.app'])
var container = {
name: 'docker.containers.container',
url: '/:id',
url: '/:id?nodeName',
views: {
'content@': {
templateUrl: 'app/docker/views/containers/edit/container.html',
@ -79,15 +79,12 @@ angular.module('portainer.docker', ['portainer.app'])
var containerCreation = {
name: 'docker.containers.new',
url: '/new',
url: '/new?nodeName&from',
views: {
'content@': {
templateUrl: 'app/docker/views/containers/create/createcontainer.html',
controller: 'CreateContainerController'
}
},
params: {
from: ''
}
};
@ -170,7 +167,7 @@ angular.module('portainer.docker', ['portainer.app'])
var image = {
name: 'docker.images.image',
url: '/:id',
url: '/:id?nodeName',
views: {
'content@': {
templateUrl: 'app/docker/views/images/edit/image.html',
@ -203,7 +200,7 @@ angular.module('portainer.docker', ['portainer.app'])
var network = {
name: 'docker.networks.network',
url: '/:id',
url: '/:id?nodeName',
views: {
'content@': {
templateUrl: 'app/docker/views/networks/edit/network.html',
@ -443,7 +440,7 @@ angular.module('portainer.docker', ['portainer.app'])
var volume = {
name: 'docker.volumes.volume',
url: '/:id',
url: '/:id?nodeName',
views: {
'content@': {
templateUrl: 'app/docker/views/volumes/edit/volume.html',

View File

@ -0,0 +1,4 @@
angular.module('portainer.docker').component('dashboardClusterAgentInfo', {
templateUrl: 'app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html',
controller: 'DashboardClusterAgentInfoController'
});

View File

@ -0,0 +1,20 @@
<rd-widget>
<rd-widget-header icon="fa-tachometer-alt" title="Cluster information"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Nodes in the cluster</td>
<td>{{ $ctrl.agentCount }}</td>
</tr>
<tr>
<td colspan="2">
<div class="btn-group" role="group" aria-label="...">
<a ui-sref="docker.swarm.visualizer"><i class="fa fa-object-group space-right" aria-hidden="true"></i>Go to cluster visualizer</a>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>

View File

@ -0,0 +1,16 @@
angular.module('portainer.docker')
.controller('DashboardClusterAgentInfoController', ['AgentService', 'Notifications',
function (AgentService, Notifications) {
var ctrl = this;
this.$onInit = function() {
AgentService.agents()
.then(function success(data) {
ctrl.agentCount = data.length;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve agent information');
});
};
}]);

View File

@ -38,7 +38,7 @@
</thead>
<tbody>
<tr dir-paginate="(key, value) in $ctrl.dataset | itemsPerPage: $ctrl.state.paginatedItemLimit" ng-class="{active: item.Checked}">
<td><a ui-sref="docker.networks.network({id: value.NetworkID})">{{ key }}</a></td>
<td><a ui-sref="docker.networks.network({ id: value.NetworkID, nodeName: $ctrl.nodeName })">{{ key }}</a></td>
<td>{{ value.IPAddress || '-' }}</td>
<td>{{ value.Gateway || '-' }}</td>
<td>{{ value.MacAddress || '-' }}</td>

View File

@ -11,6 +11,7 @@ angular.module('portainer.docker').component('containerNetworksDatatable', {
joinNetworkAction: '<',
joinNetworkActionInProgress: '<',
leaveNetworkActionInProgress: '<',
leaveNetworkAction: '<'
leaveNetworkAction: '<',
nodeName: '<'
}
});

View File

@ -156,11 +156,11 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IP' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.swarmContainers">
<a ng-click="$ctrl.changeOrderBy('Host')">
Host IP
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Host' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Host' && $ctrl.state.reverseOrder"></i>
<th ng-if="$ctrl.showHostColumn">
<a ng-click="$ctrl.changeOrderBy('NodeName')">
Host
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
@ -186,8 +186,7 @@
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="docker.containers.container({ id: item.Id })" ng-if="!$ctrl.swarmContainers">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
<a ui-sref="docker.containers.container({ id: item.Id })" ng-if="$ctrl.swarmContainers">{{ item | swarmcontainername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
<a ui-sref="docker.containers.container({ id: item.Id, nodeName: item.NodeName })">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
</td>
<td>
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ item.Status }}</span>
@ -195,16 +194,16 @@
</td>
<td ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect">
<div class="btn-group btn-group-xs" role="group" aria-label="..." style="display:inline-flex;">
<a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="docker.containers.container.stats({id: item.Id})" title="Stats"><i class="fa fa-chart-area space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="docker.containers.container.logs({id: item.Id})" title="Logs"><i class="fa fa-file-alt space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionConsole" style="margin: 0 2.5px;" ui-sref="docker.containers.container.console({id: item.Id})" title="Console"><i class="fa fa-terminal space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionInspect" style="margin: 0 2.5px;" ui-sref="docker.containers.container.inspect({id: item.Id})" title="Inspect"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="docker.containers.container.stats({id: item.Id, nodeName: item.NodeName})" title="Stats"><i class="fa fa-chart-area space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="docker.containers.container.logs({id: item.Id, nodeName: item.NodeName})" title="Logs"><i class="fa fa-file-alt space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionConsole" style="margin: 0 2.5px;" ui-sref="docker.containers.container.console({id: item.Id, nodeName: item.NodeName})" title="Console"><i class="fa fa-terminal space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionInspect" style="margin: 0 2.5px;" ui-sref="docker.containers.container.inspect({id: item.Id, nodeName: item.NodeName})" title="Inspect"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a>
</div>
</td>
<td>{{ item.StackName ? item.StackName : '-' }}</td>
<td><a ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a></td>
<td>{{ item.IP ? item.IP : '-' }}</td>
<td ng-if="$ctrl.swarmContainers">{{ item.hostIP }}</td>
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
<td>
<a ng-if="item.Ports.length > 0" ng-repeat="p in item.Ports" class="image-tag" ng-href="http://{{ $ctrl.publicUrl || p.host }}:{{p.public}}" target="_blank">
<i class="fa fa-external-link-alt" aria-hidden="true"></i> {{ p.public }}:{{ p.private }}
@ -219,10 +218,10 @@
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="8" class="text-center text-muted">Loading...</td>
<td colspan="9" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="8" class="text-center text-muted">No container available.</td>
<td colspan="9" class="text-center text-muted">No container available.</td>
</tr>
</tbody>
</table>

View File

@ -10,7 +10,7 @@ angular.module('portainer.docker').component('containersDatatable', {
reverseOrder: '<',
showTextFilter: '<',
showOwnershipColumn: '<',
swarmContainers: '<',
showHostColumn: '<',
publicUrl: '<',
containerNameTruncateSize: '<',
startAction: '<',

View File

@ -93,6 +93,13 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.showHostColumn">
<a ng-click="$ctrl.changeOrderBy('NodeName')">
Host
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
</tr>
</thead>
<tbody>
@ -102,7 +109,7 @@
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="docker.images.image({id: item.Id})" class="monospaced">{{ item.Id | truncate:20 }}</a>
<a ui-sref="docker.images.image({ id: item.Id, nodeName: item.NodeName })" class="monospaced">{{ item.Id | truncate:20 }}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="::item.ContainerCount === 0">Unused</span>
</td>
<td>
@ -110,12 +117,13 @@
</td>
<td>{{ item.VirtualSize | humansize }}</td>
<td>{{ item.Created | getisodatefromtimestamp }}</td>
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="4" class="text-center text-muted">Loading...</td>
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="4" class="text-center text-muted">No image available.</td>
<td colspan="5" class="text-center text-muted">No image available.</td>
</tr>
</tbody>
</table>

View File

@ -9,6 +9,7 @@ angular.module('portainer.docker').component('imagesDatatable', {
orderBy: '@',
reverseOrder: '<',
showTextFilter: '<',
showHostColumn: '<',
removeAction: '<',
forceRemoveAction: '<'
}

View File

@ -81,6 +81,13 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IPAM.Config[0].Gateway' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.showHostColumn">
<a ng-click="$ctrl.changeOrderBy('NodeName')">
Host
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.showOwnershipColumn">
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
Ownership
@ -97,7 +104,7 @@
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="docker.networks.network({id: item.Id})" title="{{ item.Name }}">{{ item.Name | truncate:40 }}</a>
<a ui-sref="docker.networks.network({ id: item.Id, nodeName: item.NodeName })" title="{{ item.Name }}">{{ item.Name | truncate:40 }}</a>
</td>
<td>{{ item.StackName ? item.StackName : '-' }}</td>
<td>{{ item.Scope }}</td>
@ -105,6 +112,7 @@
<td>{{ item.IPAM.Driver }}</td>
<td>{{ item.IPAM.Config[0].Subnet ? item.IPAM.Config[0].Subnet : '-' }}</td>
<td>{{ item.IPAM.Config[0].Gateway ? item.IPAM.Config[0].Gateway : '-' }}</td>
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
<td ng-if="$ctrl.showOwnershipColumn">
<span>
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
@ -113,10 +121,10 @@
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="8" class="text-center text-muted">Loading...</td>
<td colspan="9" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="8" class="text-center text-muted">No network available.</td>
<td colspan="9" class="text-center text-muted">No network available.</td>
</tr>
</tbody>
</table>

View File

@ -10,6 +10,7 @@ angular.module('portainer.docker').component('networksDatatable', {
reverseOrder: '<',
showTextFilter: '<',
showOwnershipColumn: '<',
showHostColumn: '<',
removeAction: '<'
}
});

View File

@ -1,105 +0,0 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fas" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.title }}
</div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
<i class="fa fa-search" aria-hidden="true"></i> Search
</span>
</div>
</div>
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('name')">
Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('cpu')">
CPU
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'cpu' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'cpu' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('memory')">
Memory
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'memory' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'memory' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('ip')">
IP Address
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ip' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ip' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('version')">
Engine
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'version' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'version' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('status')">
Status
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'status' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'status' && $ctrl.state.reverseOrder"></i>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td>{{ item.name }}</td>
<td>{{ item.cpu }}</td>
<td>{{ item.memory }}</td>
<td>{{ item.ip }}</td>
<td>{{ item.version }}</td>
<td><span class="label label-{{ item.status | nodestatusbadge }}">{{ item.status }}</span></td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="6" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="6" class="text-center text-muted">No node available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -1,13 +0,0 @@
angular.module('portainer.docker').component('nodesSsDatatable', {
templateUrl: 'app/docker/components/datatables/nodes-ss-datatable/nodesSSDatatable.html',
controller: 'GenericDatatableController',
bindings: {
title: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
showTextFilter: '<'
}
});

View File

@ -54,20 +54,26 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Updated' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.showLogsButton">Actions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td><a ui-sref="docker.tasks.task({id: item.Id})" class="monospaced">{{ item.Id }}</a></td>
<td>
<a ng-if="!$ctrl.agentProxy || !item.Container" ui-sref="docker.tasks.task({id: item.Id})" class="monospaced">{{ item.Service.Name }}{{ item.Slot ? '.' + item.Slot : '' }}{{ '.' + item.Id }}</a>
<a ng-if="$ctrl.agentProxy && item.Container" ui-sref="docker.containers.container({ id: item.Container.Id, nodeName: item.Container.NodeName })" class="monospaced">{{ item.Service.Name }}{{ item.Slot ? '.' + item.Slot : '' }}{{ '.' + item.Id }}</a>
</td>
<td><span class="label label-{{ item.Status.State | taskstatusbadge }}">{{ item.Status.State }}</span></td>
<td ng-if="$ctrl.showSlotColumn">{{ item.Slot ? item.Slot : '-' }}</td>
<td>{{ item.NodeId | tasknodename: $ctrl.nodes }}</td>
<td>{{ item.Updated | getisodate }}</td>
<td ng-if="$ctrl.showLogsButton">
<a ui-sref="docker.tasks.task.logs({id: item.Id})">
<td>
<a ui-sref="docker.tasks.task.logs({id: item.Id})" ng-if="$ctrl.showLogsButton" class="space-right">
<i class="fa fa-file-alt" aria-hidden="true"></i> View logs
</a>
<a ui-sref="docker.containers.container.console({ id: item.Container.Id, nodeName: item.Container.NodeName })" ng-if="$ctrl.agentProxy">
<i class="fa fa-terminal" aria-hidden="true"></i> Console
</a>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">

View File

@ -11,6 +11,7 @@ angular.module('portainer.docker').component('tasksDatatable', {
nodes: '<',
showTextFilter: '<',
showSlotColumn: '<',
showLogsButton: '<'
showLogsButton: '<',
agentProxy: '<'
}
});

View File

@ -84,6 +84,13 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Mountpoint' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.showHostColumn">
<a ng-click="$ctrl.changeOrderBy('NodeName')">
Host
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.showOwnershipColumn">
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
Ownership
@ -100,12 +107,13 @@
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="docker.volumes.volume({id: item.Id})" class="monospaced">{{ item.Id | truncate:25 }}</a>
<a ui-sref="docker.volumes.volume({ id: item.Id, nodeName: item.NodeName })" class="monospaced">{{ item.Id | truncate:25 }}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="item.dangling">Unused</span>
</td>
<td>{{ item.StackName ? item.StackName : '-' }}</td>
<td>{{ item.Driver }}</td>
<td>{{ item.Mountpoint | truncatelr }}</td>
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
<td ng-if="$ctrl.showOwnershipColumn">
<span>
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
@ -114,10 +122,10 @@
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="5" class="text-center text-muted">Loading...</td>
<td colspan="6" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="5" class="text-center text-muted">No volume available.</td>
<td colspan="6" class="text-center text-muted">No volume available.</td>
</tr>
</tbody>
</table>

View File

@ -10,6 +10,7 @@ angular.module('portainer.docker').component('volumesDatatable', {
reverseOrder: '<',
showTextFilter: '<',
showOwnershipColumn: '<',
showHostColumn: '<',
removeAction: '<'
}
});

View File

@ -162,12 +162,6 @@ angular.module('portainer.docker')
return name.substring(1, name.length);
};
})
.filter('swarmcontainername', function () {
'use strict';
return function (container) {
return _.split(container.Names[0], '/')[2];
};
})
.filter('swarmversion', function () {
'use strict';
return function (text) {

View File

@ -1,36 +1,39 @@
angular.module('portainer.docker')
.factory('InfoHelper', [function InfoHelperFactory() {
'use strict';
return {
determineEndpointMode: function(info) {
var mode = {
provider: '',
role: ''
};
if (_.startsWith(info.ServerVersion, 'swarm')) {
mode.provider = 'DOCKER_SWARM';
if (info.SystemStatus[0][1] === 'primary') {
mode.role = 'PRIMARY';
} else {
mode.role = 'REPLICA';
}
} else {
if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) {
if (info.ID === 'vSphere Integrated Containers') {
mode.provider = 'VMWARE_VIC';
} else {
mode.provider = 'DOCKER_STANDALONE';
}
} else {
mode.provider = 'DOCKER_SWARM_MODE';
if (info.Swarm.ControlAvailable) {
mode.role = 'MANAGER';
} else {
mode.role = 'WORKER';
}
}
}
var helper = {};
helper.determineEndpointMode = function(info, type) {
var mode = {
provider: '',
role: '',
agentProxy: false
};
if (type === 2) {
mode.provider = 'DOCKER_SWARM_MODE';
mode.role = 'MANAGER';
mode.agentProxy = true;
return mode;
}
if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) {
if (info.ID === 'vSphere Integrated Containers') {
mode.provider = 'VMWARE_VIC';
} else {
mode.provider = 'DOCKER_STANDALONE';
}
} else {
mode.provider = 'DOCKER_SWARM_MODE';
if (info.Swarm.ControlAvailable) {
mode.role = 'MANAGER';
} else {
mode.role = 'WORKER';
}
}
return mode;
};
return helper;
}]);

View File

@ -0,0 +1,22 @@
angular.module('portainer.docker')
.factory('LogHelper', [function LogHelperFactory() {
'use strict';
var helper = {};
// Return an array with each line being an entry.
// It will also remove any ANSI code related character sequences.
// If the skipHeaders param is specified, it will strip the 8 first characters of each line.
helper.formatLogs = function(logs, skipHeaders) {
logs = logs.replace(
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
if (skipHeaders) {
logs = logs.substring(8);
logs = logs.replace(/\n(.{8})/g, '\n\r');
}
return logs.split('\n');
};
return helper;
}]);

View File

@ -11,6 +11,7 @@ angular.module('portainer.docker')
var task = tasks[i];
if (task.ServiceId === service.Id) {
service.Tasks.push(task);
task.Service = service;
} else {
otherServicesTasks.push(task);
}

View File

@ -0,0 +1,18 @@
angular.module('portainer.docker')
.factory('TaskHelper', [function TaskHelperFactory() {
'use strict';
var helper = {};
helper.associateContainerToTask = function(task, containers) {
for (var i = 0; i < containers.length; i++) {
var container = containers[i];
if (task.ContainerId === container.Id) {
task.Container = container;
break;
}
}
};
return helper;
}]);

View File

@ -34,6 +34,9 @@ function ContainerViewModel(data) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
}
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
}
}

View File

@ -7,6 +7,10 @@ function ImageViewModel(data) {
this.RepoTags = data.RepoTags;
this.VirtualSize = data.VirtualSize;
this.ContainerCount = data.ContainerCount;
if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
}
function ImageBuildModel(data) {

View File

@ -19,5 +19,8 @@ function NetworkViewModel(data) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
}
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
}
}

View File

@ -7,4 +7,7 @@ function TaskViewModel(data) {
this.Status = data.Status;
this.ServiceId = data.ServiceID;
this.NodeId = data.NodeID;
if (data.Status && data.Status.ContainerStatus && data.Status.ContainerStatus.ContainerID) {
this.ContainerId = data.Status.ContainerStatus.ContainerID;
}
}

View File

@ -1,6 +1,7 @@
function TemplateViewModel(data) {
this.Type = data.type;
this.Name = data.name;
this.Hostname = data.hostname;
this.Title = data.title;
this.Description = data.description;
this.Note = data.note;

View File

@ -14,5 +14,8 @@ function VolumeViewModel(data) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
}
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
}
}

View File

@ -1,22 +1,38 @@
angular.module('portainer.docker')
.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider',
function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/containers/:id/:action', {
name: '@name',
endpointId: EndpointProvider.endpointID
},
{
query: {method: 'GET', params: {all: 0, action: 'json', filters: '@filters' }, isArray: true},
get: {method: 'GET', params: {action: 'json'}},
stop: {method: 'POST', params: {id: '@id', t: 5, action: 'stop'}},
restart: {method: 'POST', params: {id: '@id', t: 5, action: 'restart'}},
kill: {method: 'POST', params: {id: '@id', action: 'kill'}},
pause: {method: 'POST', params: {id: '@id', action: 'pause'}},
unpause: {method: 'POST', params: {id: '@id', action: 'unpause'}},
query: {
method: 'GET', params: { all: 0, action: 'json', filters: '@filters' },
isArray: true
},
get: {
method: 'GET', params: { action: 'json' }
},
stop: {
method: 'POST', params: { id: '@id', t: 5, action: 'stop' }
},
restart: {
method: 'POST', params: { id: '@id', t: 5, action: 'restart' }
},
kill: {
method: 'POST', params: { id: '@id', action: 'kill' }
},
pause: {
method: 'POST', params: { id: '@id', action: 'pause' }
},
unpause: {
method: 'POST', params: { id: '@id', action: 'unpause' }
},
logs: {
method: 'GET', params: { id: '@id', action: 'logs' },
timeout: 4500, ignoreLoadingBar: true,
transformResponse: logsHandler, isArray: true
transformResponse: logsHandler
},
stats: {
method: 'GET', params: { id: '@id', stream: false, action: 'stats' },
@ -40,7 +56,7 @@ angular.module('portainer.docker')
transformResponse: genericHandler
},
rename: {
method: 'POST', params: {id: '@id', action: 'rename', name: '@name'},
method: 'POST', params: { id: '@id', action: 'rename', name: '@name' },
transformResponse: genericHandler
},
exec: {

View File

@ -1,5 +1,6 @@
angular.module('portainer.docker')
.factory('Exec', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ExecFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
.factory('Exec', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider',
function ExecFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/exec/:id/:action', {
endpointId: EndpointProvider.endpointID

View File

@ -1,5 +1,6 @@
angular.module('portainer.docker')
.factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) {
.factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper',
function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/images/:id/:action', {

View File

@ -1,16 +1,30 @@
angular.module('portainer.docker')
.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider',
function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', {
id: '@id',
endpointId: EndpointProvider.endpointID
},
{
query: {method: 'GET', isArray: true},
get: {method: 'GET'},
create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler, ignoreLoadingBar: true},
remove: { method: 'DELETE', transformResponse: genericHandler },
connect: {method: 'POST', params: {action: 'connect'}},
disconnect: {method: 'POST', params: {action: 'disconnect'}}
query: {
method: 'GET', isArray: true
},
get: {
method: 'GET'
},
create: {
method: 'POST', params: {action: 'create'},
transformResponse: genericHandler, ignoreLoadingBar: true
},
remove: {
method: 'DELETE', transformResponse: genericHandler
},
connect: {
method: 'POST', params: { action: 'connect' }
},
disconnect: {
method: 'POST', params: { action: 'disconnect' }
}
});
}]);

View File

@ -45,15 +45,11 @@ function genericHandler(data) {
}
// The Docker API returns the logs as a single string.
// This handler will return an array with each line being an entry.
// It will also strip the 8 first characters of each line and remove any ANSI code related character sequences.
// This handler wraps the data in a JSON object under the "logs" property.
function logsHandler(data) {
var logs = data;
logs = logs.substring(8);
logs = logs.replace(/\n(.{8})/g, '\n\r');
logs = logs.replace(
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
return logs.split('\n');
return {
logs: data
};
}
// Image delete API returns an array on success (Docker 1.9 -> Docker 1.12).

View File

@ -1,5 +1,6 @@
angular.module('portainer.docker')
.factory('Service', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper' ,function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) {
.factory('Service', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper',
function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/services/:id/:action', {
endpointId: EndpointProvider.endpointID
@ -17,7 +18,7 @@ angular.module('portainer.docker')
logs: {
method: 'GET', params: { id: '@id', action: 'logs' },
timeout: 4500, ignoreLoadingBar: true,
transformResponse: logsHandler, isArray: true
transformResponse: logsHandler
}
});
}]);

View File

@ -6,7 +6,10 @@ angular.module('portainer.docker')
endpointId: EndpointProvider.endpointID
},
{
info: { method: 'GET', params: { action: 'info' }, ignoreLoadingBar: true },
info: {
method: 'GET', params: { action: 'info' },
ignoreLoadingBar: true
},
version: { method: 'GET', params: { action: 'version' }, ignoreLoadingBar: true, timeout: 4500 },
events: {
method: 'GET', params: { action: 'events', since: '@since', until: '@until' },

View File

@ -10,7 +10,7 @@ angular.module('portainer.docker')
logs: {
method: 'GET', params: { id: '@id', action: 'logs' },
timeout: 4500, ignoreLoadingBar: true,
transformResponse: logsHandler, isArray: true
transformResponse: logsHandler
}
});
}]);

View File

@ -1,5 +1,6 @@
angular.module('portainer.docker')
.factory('ContainerService', ['$q', 'Container', 'ResourceControlService', function ContainerServiceFactory($q, Container, ResourceControlService) {
.factory('ContainerService', ['$q', 'Container', 'ResourceControlService', 'LogHelper',
function ContainerServiceFactory($q, Container, ResourceControlService, LogHelper) {
'use strict';
var service = {};
@ -34,6 +35,34 @@ angular.module('portainer.docker')
return deferred.promise;
};
service.startContainer = function(id) {
return Container.start({ id: id }, {}).$promise;
};
service.stopContainer = function(id) {
return Container.stop({ id: id }, {}).$promise;
};
service.restartContainer = function(id) {
return Container.restart({ id: id }, {}).$promise;
};
service.killContainer = function(id) {
return Container.kill({ id: id }, {}).$promise;
};
service.pauseContainer = function(id) {
return Container.pause({ id: id }, {}).$promise;
};
service.resumeContainer = function(id) {
return Container.unpause({ id: id }, {}).$promise;
};
service.renameContainer = function(id, newContainerName) {
return Container.rename({id: id, name: newContainerName }, {}).$promise;
};
service.createContainer = function(configuration) {
var deferred = $q.defer();
Container.create(configuration).$promise
@ -50,30 +79,6 @@ angular.module('portainer.docker')
return deferred.promise;
};
service.startContainer = function(containerID) {
return Container.start({ id: containerID }, {}).$promise;
};
service.stopContainer = function(containerID) {
return Container.stop({ id: containerID }, {}).$promise;
};
service.restartContainer = function(containerID) {
return Container.restart({ id: containerID }, {}).$promise;
};
service.killContainer = function(containerID) {
return Container.kill({ id: containerID }, {}).$promise;
};
service.pauseContainer = function(containerID) {
return Container.pause({ id: containerID }, {}).$promise;
};
service.resumeContainer = function(containerID) {
return Container.unpause({ id: containerID }, {}).$promise;
};
service.createAndStartContainer = function(configuration) {
var deferred = $q.defer();
var containerID;
@ -94,7 +99,7 @@ angular.module('portainer.docker')
service.remove = function(container, removeVolumes) {
var deferred = $q.defer();
Container.remove({id: container.Id, v: (removeVolumes) ? 1 : 0, force: true}).$promise
Container.remove({ id: container.Id, v: (removeVolumes) ? 1 : 0, force: true }).$promise
.then(function success(data) {
if (data.message) {
deferred.reject({ msg: data.message, err: data.message });
@ -116,7 +121,7 @@ angular.module('portainer.docker')
service.createExec = function(execConfig) {
var deferred = $q.defer();
Container.exec(execConfig).$promise
Container.exec({}, execConfig).$promise
.then(function success(data) {
if (data.message) {
deferred.reject({ msg: data.message, err: data.message });
@ -131,7 +136,9 @@ angular.module('portainer.docker')
return deferred.promise;
};
service.logs = function(id, stdout, stderr, timestamps, tail) {
service.logs = function(id, stdout, stderr, timestamps, tail, stripHeaders) {
var deferred = $q.defer();
var parameters = {
id: id,
stdout: stdout || 0,
@ -140,13 +147,22 @@ angular.module('portainer.docker')
tail: tail || 'all'
};
return Container.logs(parameters).$promise;
Container.logs(parameters).$promise
.then(function success(data) {
var logs = LogHelper.formatLogs(data.logs, stripHeaders);
deferred.resolve(logs);
})
.catch(function error(err) {
deferred.reject(err);
});
return deferred.promise;
};
service.containerStats = function(id) {
var deferred = $q.defer();
Container.stats({id: id}).$promise
Container.stats({ id: id }).$promise
.then(function success(data) {
var containerStats = new ContainerStatsViewModel(data);
deferred.resolve(containerStats);
@ -159,11 +175,11 @@ angular.module('portainer.docker')
};
service.containerTop = function(id) {
return Container.top({id: id}).$promise;
return Container.top({ id: id }).$promise;
};
service.inspect = function(id) {
return Container.inspect({id: id}).$promise;
return Container.inspect({ id: id }).$promise;
};
return service;

View File

@ -7,7 +7,7 @@ angular.module('portainer.docker')
var deferred = $q.defer();
$timeout(function() {
Exec.resize({id: execId, height: height, width: width}).$promise
Exec.resize({}, { id: execId, height: height, width: width }).$promise
.then(function success(data) {
if (data.message) {
deferred.reject({ msg: 'Unable to exec into container', err: data.message });

View File

@ -19,7 +19,7 @@ angular.module('portainer.docker')
service.network = function(id) {
var deferred = $q.defer();
Network.get({id: id}).$promise
Network.get({ id: id }).$promise
.then(function success(data) {
var network = new NetworkViewModel(data);
deferred.resolve(network);
@ -31,7 +31,7 @@ angular.module('portainer.docker')
return deferred.promise;
};
service.networks = function(localNetworks, swarmNetworks, swarmAttachableNetworks, globalNetworks) {
service.networks = function(localNetworks, swarmNetworks, swarmAttachableNetworks) {
var deferred = $q.defer();
Network.query({}).$promise
@ -48,9 +48,6 @@ angular.module('portainer.docker')
if (swarmAttachableNetworks && network.Scope === 'swarm' && network.Attachable === true) {
return network;
}
if (globalNetworks && network.Scope === 'global') {
return network;
}
}).map(function (item) {
return new NetworkViewModel(item);
});
@ -68,5 +65,13 @@ angular.module('portainer.docker')
return Network.remove({ id: id }).$promise;
};
service.disconnectContainer = function(networkId, containerId, force) {
return Network.disconnect({ id: networkId }, { Container: containerId, Force: force }).$promise;
};
service.connectContainer = function(networkId, containerId) {
return Network.connect({ id: networkId }, { Container: containerId }).$promise;
};
return service;
}]);

Some files were not shown because too many files have changed in this diff Show More