mirror of https://github.com/portainer/portainer
commit
19bb83ba2a
|
@ -17,10 +17,17 @@ steps:
|
||||||
- yarn grunt build-webapp
|
- yarn grunt build-webapp
|
||||||
- mv api/cmd/portainer/portainer dist/
|
- 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:
|
download_docker_binary:
|
||||||
image: busybox
|
image: busybox
|
||||||
working_directory: ${{build_frontend}}
|
working_directory: ${{build_frontend}}
|
||||||
commands:
|
commands:
|
||||||
|
- echo ${{DOCKER_VERSION}}
|
||||||
- wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz
|
- 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
|
- tar -xf /tmp/docker-binaries.tgz -C /tmp
|
||||||
- mv /tmp/docker/docker dist/
|
- mv /tmp/docker/docker dist/
|
|
@ -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
|
|
@ -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.
|
|
@ -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... ?
|
|
@ -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.
|
|
@ -20,6 +20,7 @@ type Store struct {
|
||||||
TeamService *TeamService
|
TeamService *TeamService
|
||||||
TeamMembershipService *TeamMembershipService
|
TeamMembershipService *TeamMembershipService
|
||||||
EndpointService *EndpointService
|
EndpointService *EndpointService
|
||||||
|
EndpointGroupService *EndpointGroupService
|
||||||
ResourceControlService *ResourceControlService
|
ResourceControlService *ResourceControlService
|
||||||
VersionService *VersionService
|
VersionService *VersionService
|
||||||
SettingsService *SettingsService
|
SettingsService *SettingsService
|
||||||
|
@ -38,6 +39,7 @@ const (
|
||||||
teamBucketName = "teams"
|
teamBucketName = "teams"
|
||||||
teamMembershipBucketName = "team_membership"
|
teamMembershipBucketName = "team_membership"
|
||||||
endpointBucketName = "endpoints"
|
endpointBucketName = "endpoints"
|
||||||
|
endpointGroupBucketName = "endpoint_groups"
|
||||||
resourceControlBucketName = "resource_control"
|
resourceControlBucketName = "resource_control"
|
||||||
settingsBucketName = "settings"
|
settingsBucketName = "settings"
|
||||||
registryBucketName = "registries"
|
registryBucketName = "registries"
|
||||||
|
@ -53,6 +55,7 @@ func NewStore(storePath string) (*Store, error) {
|
||||||
TeamService: &TeamService{},
|
TeamService: &TeamService{},
|
||||||
TeamMembershipService: &TeamMembershipService{},
|
TeamMembershipService: &TeamMembershipService{},
|
||||||
EndpointService: &EndpointService{},
|
EndpointService: &EndpointService{},
|
||||||
|
EndpointGroupService: &EndpointGroupService{},
|
||||||
ResourceControlService: &ResourceControlService{},
|
ResourceControlService: &ResourceControlService{},
|
||||||
VersionService: &VersionService{},
|
VersionService: &VersionService{},
|
||||||
SettingsService: &SettingsService{},
|
SettingsService: &SettingsService{},
|
||||||
|
@ -64,6 +67,7 @@ func NewStore(storePath string) (*Store, error) {
|
||||||
store.TeamService.store = store
|
store.TeamService.store = store
|
||||||
store.TeamMembershipService.store = store
|
store.TeamMembershipService.store = store
|
||||||
store.EndpointService.store = store
|
store.EndpointService.store = store
|
||||||
|
store.EndpointGroupService.store = store
|
||||||
store.ResourceControlService.store = store
|
store.ResourceControlService.store = store
|
||||||
store.VersionService.store = store
|
store.VersionService.store = store
|
||||||
store.SettingsService.store = store
|
store.SettingsService.store = store
|
||||||
|
@ -94,7 +98,7 @@ func (store *Store) Open() error {
|
||||||
store.db = db
|
store.db = db
|
||||||
|
|
||||||
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
|
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
|
||||||
resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
|
endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
|
||||||
registryBucketName, dockerhubBucketName, stackBucketName}
|
registryBucketName, dockerhubBucketName, stackBucketName}
|
||||||
|
|
||||||
return db.Update(func(tx *bolt.Tx) error {
|
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.
|
// Close closes the BoltDB database.
|
||||||
func (store *Store) Close() error {
|
func (store *Store) Close() error {
|
||||||
if store.db != nil {
|
if store.db != nil {
|
||||||
|
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
|
@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
|
||||||
return json.Unmarshal(data, endpoint)
|
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.
|
// MarshalStack encodes a stack to binary format.
|
||||||
func MarshalStack(stack *portainer.Stack) ([]byte, error) {
|
func MarshalStack(stack *portainer.Stack) ([]byte, error) {
|
||||||
return json.Marshal(stack)
|
return json.Marshal(stack)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -38,6 +38,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||||
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
|
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
|
||||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(),
|
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(),
|
||||||
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).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(),
|
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
|
||||||
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).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(),
|
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),
|
||||||
|
|
|
@ -9,6 +9,7 @@ const (
|
||||||
defaultNoAuth = "false"
|
defaultNoAuth = "false"
|
||||||
defaultNoAnalytics = "false"
|
defaultNoAnalytics = "false"
|
||||||
defaultTLSVerify = "false"
|
defaultTLSVerify = "false"
|
||||||
|
defaultTLSSkipVerify = "false"
|
||||||
defaultTLSCACertPath = "/certs/ca.pem"
|
defaultTLSCACertPath = "/certs/ca.pem"
|
||||||
defaultTLSCertPath = "/certs/cert.pem"
|
defaultTLSCertPath = "/certs/cert.pem"
|
||||||
defaultTLSKeyPath = "/certs/key.pem"
|
defaultTLSKeyPath = "/certs/key.pem"
|
||||||
|
|
|
@ -7,6 +7,7 @@ const (
|
||||||
defaultNoAuth = "false"
|
defaultNoAuth = "false"
|
||||||
defaultNoAnalytics = "false"
|
defaultNoAnalytics = "false"
|
||||||
defaultTLSVerify = "false"
|
defaultTLSVerify = "false"
|
||||||
|
defaultTLSSkipVerify = "false"
|
||||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/portainer/portainer/filesystem"
|
"github.com/portainer/portainer/filesystem"
|
||||||
"github.com/portainer/portainer/git"
|
"github.com/portainer/portainer/git"
|
||||||
"github.com/portainer/portainer/http"
|
"github.com/portainer/portainer/http"
|
||||||
|
"github.com/portainer/portainer/http/client"
|
||||||
"github.com/portainer/portainer/jwt"
|
"github.com/portainer/portainer/jwt"
|
||||||
"github.com/portainer/portainer/ldap"
|
"github.com/portainer/portainer/ldap"
|
||||||
|
|
||||||
|
@ -49,6 +50,11 @@ func initStore(dataStorePath string) *bolt.Store {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = store.Init()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
err = store.MigrateData()
|
err = store.MigrateData()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -56,8 +62,8 @@ func initStore(dataStorePath string) *bolt.Store {
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
func initStackManager(assetsPath string) portainer.StackManager {
|
func initStackManager(assetsPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) {
|
||||||
return exec.NewStackManager(assetsPath)
|
return exec.NewStackManager(assetsPath, signatureService, fileService)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initJWTService(authenticationEnabled bool) portainer.JWTService {
|
func initJWTService(authenticationEnabled bool) portainer.JWTService {
|
||||||
|
@ -71,6 +77,10 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initDigitalSignatureService() portainer.DigitalSignatureService {
|
||||||
|
return &crypto.ECDSAService{}
|
||||||
|
}
|
||||||
|
|
||||||
func initCryptoService() portainer.CryptoService {
|
func initCryptoService() portainer.CryptoService {
|
||||||
return &crypto.Service{}
|
return &crypto.Service{}
|
||||||
}
|
}
|
||||||
|
@ -168,6 +178,35 @@ func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService
|
||||||
return &endpoints[0]
|
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() {
|
func main() {
|
||||||
flags := initCLI()
|
flags := initCLI()
|
||||||
|
|
||||||
|
@ -176,19 +215,29 @@ func main() {
|
||||||
store := initStore(*flags.Data)
|
store := initStore(*flags.Data)
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
stackManager := initStackManager(*flags.Assets)
|
|
||||||
|
|
||||||
jwtService := initJWTService(!*flags.NoAuth)
|
jwtService := initJWTService(!*flags.NoAuth)
|
||||||
|
|
||||||
cryptoService := initCryptoService()
|
cryptoService := initCryptoService()
|
||||||
|
|
||||||
|
digitalSignatureService := initDigitalSignatureService()
|
||||||
|
|
||||||
ldapService := initLDAPService()
|
ldapService := initLDAPService()
|
||||||
|
|
||||||
gitService := initGitService()
|
gitService := initGitService()
|
||||||
|
|
||||||
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
|
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 {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -209,9 +258,10 @@ func main() {
|
||||||
endpoint := &portainer.Endpoint{
|
endpoint := &portainer.Endpoint{
|
||||||
Name: "primary",
|
Name: "primary",
|
||||||
URL: *flags.Endpoint,
|
URL: *flags.Endpoint,
|
||||||
|
Type: portainer.DockerEnvironment,
|
||||||
TLSConfig: portainer.TLSConfiguration{
|
TLSConfig: portainer.TLSConfiguration{
|
||||||
TLS: *flags.TLSVerify,
|
TLS: *flags.TLSVerify,
|
||||||
TLSSkipVerify: false,
|
TLSSkipVerify: *flags.TLSSkipVerify,
|
||||||
TLSCACertPath: *flags.TLSCacert,
|
TLSCACertPath: *flags.TLSCacert,
|
||||||
TLSCertPath: *flags.TLSCert,
|
TLSCertPath: *flags.TLSCert,
|
||||||
TLSKeyPath: *flags.TLSKey,
|
TLSKeyPath: *flags.TLSKey,
|
||||||
|
@ -220,6 +270,16 @@ func main() {
|
||||||
AuthorizedTeams: []portainer.TeamID{},
|
AuthorizedTeams: []portainer.TeamID{},
|
||||||
Extensions: []portainer.EndpointExtension{},
|
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)
|
err = store.EndpointService.CreateEndpoint(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -275,6 +335,7 @@ func main() {
|
||||||
TeamService: store.TeamService,
|
TeamService: store.TeamService,
|
||||||
TeamMembershipService: store.TeamMembershipService,
|
TeamMembershipService: store.TeamMembershipService,
|
||||||
EndpointService: store.EndpointService,
|
EndpointService: store.EndpointService,
|
||||||
|
EndpointGroupService: store.EndpointGroupService,
|
||||||
ResourceControlService: store.ResourceControlService,
|
ResourceControlService: store.ResourceControlService,
|
||||||
SettingsService: store.SettingsService,
|
SettingsService: store.SettingsService,
|
||||||
RegistryService: store.RegistryService,
|
RegistryService: store.RegistryService,
|
||||||
|
@ -286,6 +347,7 @@ func main() {
|
||||||
FileService: fileService,
|
FileService: fileService,
|
||||||
LDAPService: ldapService,
|
LDAPService: ldapService,
|
||||||
GitService: gitService,
|
GitService: gitService,
|
||||||
|
SignatureService: digitalSignatureService,
|
||||||
SSL: *flags.SSL,
|
SSL: *flags.SSL,
|
||||||
SSLCert: *flags.SSLCert,
|
SSLCert: *flags.SSLCert,
|
||||||
SSLKey: *flags.SSLKey,
|
SSLKey: *flags.SSLKey,
|
||||||
|
|
|
@ -142,8 +142,6 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
|
||||||
if endpoint != nil {
|
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)
|
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)
|
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 {
|
} 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)
|
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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -8,11 +8,32 @@ import (
|
||||||
"github.com/portainer/portainer"
|
"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
|
// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key
|
||||||
func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) {
|
func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) {
|
||||||
TLSConfig := &tls.Config{}
|
TLSConfig := &tls.Config{}
|
||||||
|
|
||||||
if config.TLSCertPath != "" && config.TLSKeyPath != "" {
|
if config.TLS && config.TLSCertPath != "" && config.TLSKeyPath != "" {
|
||||||
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
|
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -21,7 +42,7 @@ func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, er
|
||||||
TLSConfig.Certificates = []tls.Certificate{cert}
|
TLSConfig.Certificates = []tls.Certificate{cert}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !config.TLSSkipVerify {
|
if config.TLS && !config.TLSSkipVerify {
|
||||||
caCert, err := ioutil.ReadFile(config.TLSCACertPath)
|
caCert, err := ioutil.ReadFile(config.TLSCACertPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
@ -28,7 +28,7 @@ const (
|
||||||
// TeamMembership errors.
|
// TeamMembership errors.
|
||||||
const (
|
const (
|
||||||
ErrTeamMembershipNotFound = Error("Team membership not found")
|
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.
|
// ResourceControl errors.
|
||||||
|
@ -44,6 +44,12 @@ const (
|
||||||
ErrEndpointAccessDenied = Error("Access denied to endpoint")
|
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.
|
// Registry errors.
|
||||||
const (
|
const (
|
||||||
ErrRegistryNotFound = Error("Registry not found")
|
ErrRegistryNotFound = Error("Registry not found")
|
||||||
|
|
|
@ -12,14 +12,34 @@ import (
|
||||||
|
|
||||||
// StackManager represents a service for managing stacks.
|
// StackManager represents a service for managing stacks.
|
||||||
type StackManager struct {
|
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.
|
// NewStackManager initializes a new StackManager service.
|
||||||
func NewStackManager(binaryPath string) *StackManager {
|
// It also updates the configuration of the Docker CLI binary.
|
||||||
return &StackManager{
|
func NewStackManager(binaryPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) {
|
||||||
binaryPath: binaryPath,
|
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).
|
// 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)
|
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.
|
// Remove executes the docker stack rm command.
|
||||||
|
@ -99,9 +120,12 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
args := make([]string, 0)
|
args := make([]string, 0)
|
||||||
|
args = append(args, "--config", binaryPath)
|
||||||
args = append(args, "-H", endpoint.URL)
|
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")
|
args = append(args, "--tls")
|
||||||
|
|
||||||
if !endpoint.TLSConfig.TLSSkipVerify {
|
if !endpoint.TLSConfig.TLSSkipVerify {
|
||||||
|
@ -115,3 +139,22 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
|
||||||
|
|
||||||
return command, args
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ package filesystem
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
|
@ -26,6 +28,10 @@ const (
|
||||||
ComposeStorePath = "compose"
|
ComposeStorePath = "compose"
|
||||||
// ComposeFileDefaultName represents the default name of a compose file.
|
// ComposeFileDefaultName represents the default name of a compose file.
|
||||||
ComposeFileDefaultName = "docker-compose.yml"
|
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.
|
// 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
|
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
|
// createDirectoryInStore creates a new directory in the file store
|
||||||
func (service *Service) createDirectoryInStore(name string) error {
|
func (service *Service) createDirectoryInStore(name string) error {
|
||||||
path := path.Join(service.fileStorePath, name)
|
path := path.Join(service.fileStorePath, name)
|
||||||
|
@ -221,3 +290,43 @@ func (service *Service) createFileInStore(filePath string, r io.Reader) error {
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.
|
||||||
logger.Printf("http error: %s (code=%d)", err, code)
|
logger.Printf("http error: %s (code=%d)", err, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(code)
|
w.WriteHeader(code)
|
||||||
json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()})
|
json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()})
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,14 +37,14 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewAuthHandler returns a new instance of AuthHandler.
|
// 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{
|
h := &AuthHandler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||||
authDisabled: authDisabled,
|
authDisabled: authDisabled,
|
||||||
}
|
}
|
||||||
h.Handle("/auth",
|
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
|
return h
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ type DockerHandler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
EndpointService portainer.EndpointService
|
EndpointService portainer.EndpointService
|
||||||
|
EndpointGroupService portainer.EndpointGroupService
|
||||||
TeamMembershipService portainer.TeamMembershipService
|
TeamMembershipService portainer.TeamMembershipService
|
||||||
ProxyManager *proxy.Manager
|
ProxyManager *proxy.Manager
|
||||||
}
|
}
|
||||||
|
@ -64,9 +65,17 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) {
|
if tokenData.Role != portainer.AdministratorRole {
|
||||||
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
|
group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
|
||||||
return
|
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
|
var proxy http.Handler
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
|
"github.com/portainer/portainer/crypto"
|
||||||
|
"github.com/portainer/portainer/http/client"
|
||||||
httperror "github.com/portainer/portainer/http/error"
|
httperror "github.com/portainer/portainer/http/error"
|
||||||
"github.com/portainer/portainer/http/proxy"
|
"github.com/portainer/portainer/http/proxy"
|
||||||
"github.com/portainer/portainer/http/security"
|
"github.com/portainer/portainer/http/security"
|
||||||
|
@ -22,6 +27,7 @@ type EndpointHandler struct {
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
authorizeEndpointManagement bool
|
authorizeEndpointManagement bool
|
||||||
EndpointService portainer.EndpointService
|
EndpointService portainer.EndpointService
|
||||||
|
EndpointGroupService portainer.EndpointGroupService
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
ProxyManager *proxy.Manager
|
ProxyManager *proxy.Manager
|
||||||
}
|
}
|
||||||
|
@ -56,19 +62,6 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag
|
||||||
}
|
}
|
||||||
|
|
||||||
type (
|
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 {
|
putEndpointAccessRequest struct {
|
||||||
AuthorizedUsers []int `valid:"-"`
|
AuthorizedUsers []int `valid:"-"`
|
||||||
AuthorizedTeams []int `valid:"-"`
|
AuthorizedTeams []int `valid:"-"`
|
||||||
|
@ -78,10 +71,24 @@ type (
|
||||||
Name string `valid:"-"`
|
Name string `valid:"-"`
|
||||||
URL string `valid:"-"`
|
URL string `valid:"-"`
|
||||||
PublicURL string `valid:"-"`
|
PublicURL string `valid:"-"`
|
||||||
|
GroupID int `valid:"-"`
|
||||||
TLS bool `valid:"-"`
|
TLS bool `valid:"-"`
|
||||||
TLSSkipVerify bool `valid:"-"`
|
TLSSkipVerify bool `valid:"-"`
|
||||||
TLSSkipClientVerify 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
|
// handleGetEndpoints handles GET requests on /endpoints
|
||||||
|
@ -98,7 +105,13 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||||
return
|
return
|
||||||
|
@ -107,6 +120,180 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
|
||||||
encodeJSON(w, filteredEndpoints, handler.Logger)
|
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
|
// handlePostEndpoints handles POST requests on /endpoints
|
||||||
func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) {
|
func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) {
|
||||||
if !handler.authorizeEndpointManagement {
|
if !handler.authorizeEndpointManagement {
|
||||||
|
@ -114,60 +301,19 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req postEndpointsRequest
|
payload, err := convertPostEndpointRequestToPayload(r)
|
||||||
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 {
|
if err != nil {
|
||||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := &portainer.Endpoint{
|
endpoint, err := handler.createEndpoint(payload)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.TLS {
|
encodeJSON(w, &endpoint, handler.Logger)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetEndpoint handles GET requests on /endpoints/:id
|
// handleGetEndpoint handles GET requests on /endpoints/:id
|
||||||
|
@ -297,6 +443,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
|
||||||
endpoint.PublicURL = req.PublicURL
|
endpoint.PublicURL = req.PublicURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.GroupID != 0 {
|
||||||
|
endpoint.GroupID = portainer.EndpointGroupID(req.GroupID)
|
||||||
|
}
|
||||||
|
|
||||||
folder := strconv.Itoa(int(endpoint.ID))
|
folder := strconv.Itoa(int(endpoint.ID))
|
||||||
if req.TLS {
|
if req.TLS {
|
||||||
endpoint.TLSConfig.TLS = true
|
endpoint.TLSConfig.TLS = true
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,6 +20,7 @@ type StoridgeHandler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
EndpointService portainer.EndpointService
|
EndpointService portainer.EndpointService
|
||||||
|
EndpointGroupService portainer.EndpointGroupService
|
||||||
TeamMembershipService portainer.TeamMembershipService
|
TeamMembershipService portainer.TeamMembershipService
|
||||||
ProxyManager *proxy.Manager
|
ProxyManager *proxy.Manager
|
||||||
}
|
}
|
||||||
|
@ -64,9 +65,17 @@ func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) {
|
if tokenData.Role != portainer.AdministratorRole {
|
||||||
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
|
group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
|
||||||
return
|
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
|
var storidgeExtension *portainer.EndpointExtension
|
||||||
|
|
|
@ -2,6 +2,7 @@ package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -18,6 +19,7 @@ type Handler struct {
|
||||||
TeamHandler *TeamHandler
|
TeamHandler *TeamHandler
|
||||||
TeamMembershipHandler *TeamMembershipHandler
|
TeamMembershipHandler *TeamMembershipHandler
|
||||||
EndpointHandler *EndpointHandler
|
EndpointHandler *EndpointHandler
|
||||||
|
EndpointGroupHandler *EndpointGroupHandler
|
||||||
RegistryHandler *RegistryHandler
|
RegistryHandler *RegistryHandler
|
||||||
DockerHubHandler *DockerHubHandler
|
DockerHubHandler *DockerHubHandler
|
||||||
ExtensionHandler *ExtensionHandler
|
ExtensionHandler *ExtensionHandler
|
||||||
|
@ -50,6 +52,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
|
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
|
||||||
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
|
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"):
|
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(r.URL.Path, "/docker/"):
|
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.
|
// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails.
|
||||||
func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) {
|
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 {
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger)
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -16,119 +16,135 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/koding/websocketproxy"
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
"github.com/portainer/portainer/crypto"
|
"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 (
|
||||||
type WebSocketHandler struct {
|
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
|
||||||
*mux.Router
|
WebSocketHandler struct {
|
||||||
Logger *log.Logger
|
*mux.Router
|
||||||
EndpointService portainer.EndpointService
|
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.
|
// NewWebSocketHandler returns a new instance of WebSocketHandler.
|
||||||
func NewWebSocketHandler() *WebSocketHandler {
|
func NewWebSocketHandler() *WebSocketHandler {
|
||||||
h := &WebSocketHandler{
|
h := &WebSocketHandler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
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
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) {
|
// handleWebsocketExec handles GET requests on /websocket/exec?id=<execID>&endpointId=<endpointID>&nodeName=<nodeName>
|
||||||
qry := ws.Request().URL.Query()
|
// If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint.
|
||||||
execID := qry.Get("id")
|
// If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and
|
||||||
edpID := qry.Get("endpointId")
|
// an ExecStart operation HTTP request will be created and hijacked.
|
||||||
|
func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *http.Request) {
|
||||||
parsedID, err := strconv.Atoi(edpID)
|
paramExecID := r.FormValue("id")
|
||||||
if err != nil {
|
paramEndpointID := r.FormValue("endpointId")
|
||||||
log.Printf("Unable to parse endpoint ID: %s", err)
|
if paramExecID == "" || paramEndpointID == "" {
|
||||||
|
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointID := portainer.EndpointID(parsedID)
|
endpointID, err := strconv.Atoi(paramEndpointID)
|
||||||
endpoint, err := handler.EndpointService.Endpoint(endpointID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Unable to retrieve endpoint: %s", err)
|
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointURL, err := url.Parse(endpoint.URL)
|
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Unable to parse endpoint URL: %s", err)
|
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var host string
|
params := &webSocketExecRequestParams{
|
||||||
if endpointURL.Scheme == "tcp" {
|
endpoint: endpoint,
|
||||||
host = endpointURL.Host
|
execID: paramExecID,
|
||||||
} else if endpointURL.Scheme == "unix" {
|
nodeName: r.FormValue("nodeName"),
|
||||||
host = endpointURL.Path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Should not be managed here
|
err = handler.handleRequest(w, r, params)
|
||||||
var tlsConfig *tls.Config
|
if err != nil {
|
||||||
if endpoint.TLSConfig.TLS {
|
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||||
tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
|
return
|
||||||
if err != nil {
|
}
|
||||||
log.Fatalf("Unable to create TLS configuration: %s", err)
|
}
|
||||||
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 {
|
signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
|
||||||
log.Fatalf("error during hijack: %s", err)
|
if err != nil {
|
||||||
return
|
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 {
|
func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error {
|
||||||
Tty bool
|
dial, err := createDial(endpoint)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error marshaling exec config: %s", err)
|
return 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When we set up a TCP connection for hijack, there could be long periods
|
// 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.SetKeepAlive(true)
|
||||||
tcpConn.SetKeepAlivePeriod(30 * time.Second)
|
tcpConn.SetKeepAlivePeriod(30 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
httpConn := httputil.NewClientConn(dial, nil)
|
||||||
|
defer httpConn.Close()
|
||||||
|
|
||||||
|
execStartRequest, err := createExecStartRequest(execID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
clientconn := httputil.NewClientConn(dial, nil)
|
|
||||||
defer clientconn.Close()
|
|
||||||
|
|
||||||
// Server hijacks the connection, error 'connection closed' expected
|
err = hijackRequest(websocketConn, httpConn, execStartRequest)
|
||||||
clientconn.Do(req)
|
if err != nil {
|
||||||
|
return err
|
||||||
rwc, br := clientconn.Hijack()
|
|
||||||
defer rwc.Close()
|
|
||||||
|
|
||||||
if started != nil {
|
|
||||||
started <- rwc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -98,9 +98,12 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team
|
||||||
}
|
}
|
||||||
|
|
||||||
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
|
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
|
||||||
metadata := make(map[string]interface{})
|
if object["Portainer"] == nil {
|
||||||
metadata["ResourceControl"] = resourceControl
|
object["Portainer"] = make(map[string]interface{})
|
||||||
object["Portainer"] = metadata
|
}
|
||||||
|
|
||||||
|
portainerMetadata := object["Portainer"].(map[string]interface{})
|
||||||
|
portainerMetadata["ResourceControl"] = resourceControl
|
||||||
return object
|
return object
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ const (
|
||||||
|
|
||||||
// configListOperation extracts the response as a JSON object, loop through the configs array
|
// 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
|
// 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
|
var err error
|
||||||
|
|
||||||
// ConfigList response is a JSON array
|
// 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
|
// 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)
|
// 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.
|
// 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
|
// ConfigInspect response is a JSON object
|
||||||
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
|
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
|
||||||
responseObject, err := getResponseAsJSONOBject(response)
|
responseObject, err := getResponseAsJSONOBject(response)
|
||||||
|
|
|
@ -16,7 +16,7 @@ const (
|
||||||
|
|
||||||
// containerListOperation extracts the response as a JSON object, loop through the containers array
|
// 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
|
// 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
|
var err error
|
||||||
// ContainerList response is a JSON array
|
// ContainerList response is a JSON array
|
||||||
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
// 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
|
// 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)
|
// 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.
|
// 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
|
// ContainerInspect response is a JSON object
|
||||||
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
|
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
|
||||||
responseObject, err := getResponseAsJSONOBject(response)
|
responseObject, err := getResponseAsJSONOBject(response)
|
||||||
|
|
|
@ -17,6 +17,7 @@ type proxyFactory struct {
|
||||||
SettingsService portainer.SettingsService
|
SettingsService portainer.SettingsService
|
||||||
RegistryService portainer.RegistryService
|
RegistryService portainer.RegistryService
|
||||||
DockerHubService portainer.DockerHubService
|
DockerHubService portainer.DockerHubService
|
||||||
|
SignatureService portainer.DigitalSignatureService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler {
|
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)
|
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"
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -36,14 +38,15 @@ func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer
|
||||||
return proxy, nil
|
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"
|
u.Scheme = "http"
|
||||||
return factory.createDockerReverseProxy(u)
|
return factory.createDockerReverseProxy(u, enableSignature)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
|
func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
|
||||||
proxy := &socketProxy{}
|
proxy := &socketProxy{}
|
||||||
transport := &proxyTransport{
|
transport := &proxyTransport{
|
||||||
|
enableSignature: false,
|
||||||
ResourceControlService: factory.ResourceControlService,
|
ResourceControlService: factory.ResourceControlService,
|
||||||
TeamMembershipService: factory.TeamMembershipService,
|
TeamMembershipService: factory.TeamMembershipService,
|
||||||
SettingsService: factory.SettingsService,
|
SettingsService: factory.SettingsService,
|
||||||
|
@ -55,9 +58,10 @@ func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
|
||||||
return proxy
|
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)
|
proxy := newSingleHostReverseProxyWithHostHeader(u)
|
||||||
transport := &proxyTransport{
|
transport := &proxyTransport{
|
||||||
|
enableSignature: enableSignature,
|
||||||
ResourceControlService: factory.ResourceControlService,
|
ResourceControlService: factory.ResourceControlService,
|
||||||
TeamMembershipService: factory.TeamMembershipService,
|
TeamMembershipService: factory.TeamMembershipService,
|
||||||
SettingsService: factory.SettingsService,
|
SettingsService: factory.SettingsService,
|
||||||
|
@ -65,6 +69,11 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.Reve
|
||||||
DockerHubService: factory.DockerHubService,
|
DockerHubService: factory.DockerHubService,
|
||||||
dockerTransport: &http.Transport{},
|
dockerTransport: &http.Transport{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if enableSignature {
|
||||||
|
transport.SignatureService = factory.SignatureService
|
||||||
|
}
|
||||||
|
|
||||||
proxy.Transport = transport
|
proxy.Transport = transport
|
||||||
return proxy
|
return proxy
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,24 +9,37 @@ import (
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager represents a service used to manage Docker proxies.
|
type (
|
||||||
type Manager struct {
|
// Manager represents a service used to manage Docker proxies.
|
||||||
proxyFactory *proxyFactory
|
Manager struct {
|
||||||
proxies cmap.ConcurrentMap
|
proxyFactory *proxyFactory
|
||||||
extensionProxies cmap.ConcurrentMap
|
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
|
// 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{
|
return &Manager{
|
||||||
proxies: cmap.New(),
|
proxies: cmap.New(),
|
||||||
extensionProxies: cmap.New(),
|
extensionProxies: cmap.New(),
|
||||||
proxyFactory: &proxyFactory{
|
proxyFactory: &proxyFactory{
|
||||||
ResourceControlService: resourceControlService,
|
ResourceControlService: parameters.ResourceControlService,
|
||||||
TeamMembershipService: teamMembershipService,
|
TeamMembershipService: parameters.TeamMembershipService,
|
||||||
SettingsService: settingsService,
|
SettingsService: parameters.SettingsService,
|
||||||
RegistryService: registryService,
|
RegistryService: parameters.RegistryService,
|
||||||
DockerHubService: dockerHubService,
|
DockerHubService: parameters.DockerHubService,
|
||||||
|
SignatureService: parameters.SignatureService,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -41,14 +54,19 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enableSignature := false
|
||||||
|
if endpoint.Type == portainer.AgentOnDockerEnvironment {
|
||||||
|
enableSignature = true
|
||||||
|
}
|
||||||
|
|
||||||
if endpointURL.Scheme == "tcp" {
|
if endpointURL.Scheme == "tcp" {
|
||||||
if endpoint.TLSConfig.TLS {
|
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
|
||||||
proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, endpoint)
|
proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL)
|
proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL, enableSignature)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Assume unix:// scheme
|
// Assume unix:// scheme
|
||||||
|
|
|
@ -15,7 +15,7 @@ const (
|
||||||
|
|
||||||
// networkListOperation extracts the response as a JSON object, loop through the networks array
|
// 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
|
// 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
|
var err error
|
||||||
// NetworkList response is a JSON array
|
// NetworkList response is a JSON array
|
||||||
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
|
// 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
|
// 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
|
// has access to the network based on resource control and either rewrite an access denied response
|
||||||
// or a decorated network.
|
// 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
|
// NetworkInspect response is a JSON object
|
||||||
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
|
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
|
||||||
responseObject, err := getResponseAsJSONOBject(response)
|
responseObject, err := getResponseAsJSONOBject(response)
|
||||||
|
|
|
@ -13,6 +13,8 @@ import (
|
||||||
const (
|
const (
|
||||||
// ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse
|
// 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")
|
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{} {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
responseObject := responseData.([]interface{})
|
switch responseObject := responseData.(type) {
|
||||||
return responseObject, nil
|
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) {
|
func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) {
|
||||||
|
|
|
@ -14,7 +14,7 @@ const (
|
||||||
|
|
||||||
// secretListOperation extracts the response as a JSON object, loop through the secrets array
|
// 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
|
// 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
|
var err error
|
||||||
|
|
||||||
// SecretList response is a JSON array
|
// 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
|
// 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)
|
// 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.
|
// 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
|
// SecretInspect response is a JSON object
|
||||||
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
|
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
|
||||||
responseObject, err := getResponseAsJSONOBject(response)
|
responseObject, err := getResponseAsJSONOBject(response)
|
||||||
|
|
|
@ -15,7 +15,7 @@ const (
|
||||||
|
|
||||||
// serviceListOperation extracts the response as a JSON array, loop through the service array
|
// 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
|
// 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
|
var err error
|
||||||
// ServiceList response is a JSON array
|
// ServiceList response is a JSON array
|
||||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
// 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
|
// 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
|
// has access to the service based on resource control and either rewrite an access denied response
|
||||||
// or a decorated service.
|
// 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
|
// ServiceInspect response is a JSON object
|
||||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
|
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
|
||||||
responseObject, err := getResponseAsJSONOBject(response)
|
responseObject, err := getResponseAsJSONOBject(response)
|
||||||
|
|
|
@ -15,7 +15,7 @@ const (
|
||||||
|
|
||||||
// taskListOperation extracts the response as a JSON object, loop through the tasks array
|
// 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
|
// 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
|
var err error
|
||||||
|
|
||||||
// TaskList response is a JSON array
|
// TaskList response is a JSON array
|
||||||
|
|
|
@ -5,20 +5,25 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
"github.com/portainer/portainer/http/security"
|
"github.com/portainer/portainer/http/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
proxyTransport struct {
|
proxyTransport struct {
|
||||||
dockerTransport *http.Transport
|
dockerTransport *http.Transport
|
||||||
|
enableSignature bool
|
||||||
ResourceControlService portainer.ResourceControlService
|
ResourceControlService portainer.ResourceControlService
|
||||||
TeamMembershipService portainer.TeamMembershipService
|
TeamMembershipService portainer.TeamMembershipService
|
||||||
RegistryService portainer.RegistryService
|
RegistryService portainer.RegistryService
|
||||||
DockerHubService portainer.DockerHubService
|
DockerHubService portainer.DockerHubService
|
||||||
SettingsService portainer.SettingsService
|
SettingsService portainer.SettingsService
|
||||||
|
SignatureService portainer.DigitalSignatureService
|
||||||
}
|
}
|
||||||
restrictedOperationContext struct {
|
restrictedOperationContext struct {
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
|
@ -42,7 +47,7 @@ type (
|
||||||
operationContext *restrictedOperationContext
|
operationContext *restrictedOperationContext
|
||||||
labelBlackList []portainer.Pair
|
labelBlackList []portainer.Pair
|
||||||
}
|
}
|
||||||
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
|
restrictedOperationRequest func(*http.Response, *operationExecutor) error
|
||||||
operationRequest func(*http.Request) 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) {
|
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 {
|
switch {
|
||||||
case strings.HasPrefix(path, "/configs"):
|
case strings.HasPrefix(path, "/configs"):
|
||||||
|
@ -258,7 +274,7 @@ func (p *proxyTransport) proxyImageRequest(request *http.Request) (*http.Respons
|
||||||
case "/images/create":
|
case "/images/create":
|
||||||
return p.replaceRegistryAuthenticationHeader(request)
|
return p.replaceRegistryAuthenticationHeader(request)
|
||||||
default:
|
default:
|
||||||
if match, _ := path.Match("/images/*/push", requestPath); match {
|
if path.Base(requestPath) == "push" && request.Method == http.MethodPost {
|
||||||
return p.replaceRegistryAuthenticationHeader(request)
|
return p.replaceRegistryAuthenticationHeader(request)
|
||||||
}
|
}
|
||||||
return p.executeDockerRequest(request)
|
return p.executeDockerRequest(request)
|
||||||
|
@ -388,7 +404,7 @@ func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request,
|
||||||
return response, err
|
return response, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = operation(request, response, executor)
|
err = operation(response, executor)
|
||||||
return response, err
|
return response, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ const (
|
||||||
|
|
||||||
// volumeListOperation extracts the response as a JSON object, loop through the volume array
|
// 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
|
// 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
|
var err error
|
||||||
// VolumeList response is a JSON object
|
// VolumeList response is a JSON object
|
||||||
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
// 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
|
// 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
|
// has access to the volume based on any existing resource control and either rewrite an access denied response
|
||||||
// or a decorated volume.
|
// 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
|
// VolumeInspect response is a JSON object
|
||||||
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
|
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
|
||||||
responseObject, err := getResponseAsJSONOBject(response)
|
responseObject, err := getResponseAsJSONOBject(response)
|
||||||
|
|
|
@ -124,34 +124,37 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques
|
||||||
|
|
||||||
// AuthorizedEndpointAccess ensure that the user can access the specified endpoint.
|
// 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
|
// 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.
|
// listed in the authorized teams.
|
||||||
func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
func AuthorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||||
for _, authorizedUserID := range endpoint.AuthorizedUsers {
|
return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
|
||||||
if authorizedUserID == userID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, membership := range memberships {
|
|
||||||
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
|
|
||||||
if membership.TeamID == authorizedTeamID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthorizedRegistryAccess ensure that the user can access the specified registry.
|
// 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
|
// It will check if the user is part of the authorized users or part of a team that is
|
||||||
// listed in the authorized teams.
|
// listed in the authorized teams.
|
||||||
func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
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 {
|
if authorizedUserID == userID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, membership := range memberships {
|
for _, membership := range memberships {
|
||||||
for _, authorizedTeamID := range registry.AuthorizedTeams {
|
for _, authorizedTeamID := range authorizedTeams {
|
||||||
if membership.TeamID == authorizedTeamID {
|
if membership.TeamID == authorizedTeamID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,15 +79,17 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilterEndpoints filters endpoints based on user role and team memberships.
|
// FilterEndpoints filters endpoints based on user role and team memberships.
|
||||||
// Non administrator users only have access to authorized endpoints.
|
// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups).
|
||||||
func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
|
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
|
||||||
filteredEndpoints := endpoints
|
filteredEndpoints := endpoints
|
||||||
|
|
||||||
if !context.IsAdmin {
|
if !context.IsAdmin {
|
||||||
filteredEndpoints = make([]portainer.Endpoint, 0)
|
filteredEndpoints = make([]portainer.Endpoint, 0)
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
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)
|
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -95,3 +97,30 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC
|
||||||
|
|
||||||
return filteredEndpoints, nil
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
"github.com/portainer/portainer/http/handler"
|
"github.com/portainer/portainer/http/handler"
|
||||||
"github.com/portainer/portainer/http/handler/extensions"
|
"github.com/portainer/portainer/http/handler/extensions"
|
||||||
|
@ -22,6 +24,7 @@ type Server struct {
|
||||||
TeamService portainer.TeamService
|
TeamService portainer.TeamService
|
||||||
TeamMembershipService portainer.TeamMembershipService
|
TeamMembershipService portainer.TeamMembershipService
|
||||||
EndpointService portainer.EndpointService
|
EndpointService portainer.EndpointService
|
||||||
|
EndpointGroupService portainer.EndpointGroupService
|
||||||
ResourceControlService portainer.ResourceControlService
|
ResourceControlService portainer.ResourceControlService
|
||||||
SettingsService portainer.SettingsService
|
SettingsService portainer.SettingsService
|
||||||
CryptoService portainer.CryptoService
|
CryptoService portainer.CryptoService
|
||||||
|
@ -33,6 +36,7 @@ type Server struct {
|
||||||
StackManager portainer.StackManager
|
StackManager portainer.StackManager
|
||||||
LDAPService portainer.LDAPService
|
LDAPService portainer.LDAPService
|
||||||
GitService portainer.GitService
|
GitService portainer.GitService
|
||||||
|
SignatureService portainer.DigitalSignatureService
|
||||||
Handler *handler.Handler
|
Handler *handler.Handler
|
||||||
SSL bool
|
SSL bool
|
||||||
SSLCert string
|
SSLCert string
|
||||||
|
@ -42,10 +46,19 @@ type Server struct {
|
||||||
// Start starts the HTTP server
|
// Start starts the HTTP server
|
||||||
func (server *Server) Start() error {
|
func (server *Server) Start() error {
|
||||||
requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled)
|
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 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.UserService = server.UserService
|
||||||
authHandler.CryptoService = server.CryptoService
|
authHandler.CryptoService = server.CryptoService
|
||||||
authHandler.JWTService = server.JWTService
|
authHandler.JWTService = server.JWTService
|
||||||
|
@ -72,14 +85,20 @@ func (server *Server) Start() error {
|
||||||
templatesHandler.SettingsService = server.SettingsService
|
templatesHandler.SettingsService = server.SettingsService
|
||||||
var dockerHandler = handler.NewDockerHandler(requestBouncer)
|
var dockerHandler = handler.NewDockerHandler(requestBouncer)
|
||||||
dockerHandler.EndpointService = server.EndpointService
|
dockerHandler.EndpointService = server.EndpointService
|
||||||
|
dockerHandler.EndpointGroupService = server.EndpointGroupService
|
||||||
dockerHandler.TeamMembershipService = server.TeamMembershipService
|
dockerHandler.TeamMembershipService = server.TeamMembershipService
|
||||||
dockerHandler.ProxyManager = proxyManager
|
dockerHandler.ProxyManager = proxyManager
|
||||||
var websocketHandler = handler.NewWebSocketHandler()
|
var websocketHandler = handler.NewWebSocketHandler()
|
||||||
websocketHandler.EndpointService = server.EndpointService
|
websocketHandler.EndpointService = server.EndpointService
|
||||||
|
websocketHandler.SignatureService = server.SignatureService
|
||||||
var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement)
|
var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement)
|
||||||
endpointHandler.EndpointService = server.EndpointService
|
endpointHandler.EndpointService = server.EndpointService
|
||||||
|
endpointHandler.EndpointGroupService = server.EndpointGroupService
|
||||||
endpointHandler.FileService = server.FileService
|
endpointHandler.FileService = server.FileService
|
||||||
endpointHandler.ProxyManager = proxyManager
|
endpointHandler.ProxyManager = proxyManager
|
||||||
|
var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer)
|
||||||
|
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
|
||||||
|
endpointGroupHandler.EndpointService = server.EndpointService
|
||||||
var registryHandler = handler.NewRegistryHandler(requestBouncer)
|
var registryHandler = handler.NewRegistryHandler(requestBouncer)
|
||||||
registryHandler.RegistryService = server.RegistryService
|
registryHandler.RegistryService = server.RegistryService
|
||||||
var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer)
|
var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer)
|
||||||
|
@ -102,6 +121,7 @@ func (server *Server) Start() error {
|
||||||
extensionHandler.ProxyManager = proxyManager
|
extensionHandler.ProxyManager = proxyManager
|
||||||
var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer)
|
var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer)
|
||||||
storidgeHandler.EndpointService = server.EndpointService
|
storidgeHandler.EndpointService = server.EndpointService
|
||||||
|
storidgeHandler.EndpointGroupService = server.EndpointGroupService
|
||||||
storidgeHandler.TeamMembershipService = server.TeamMembershipService
|
storidgeHandler.TeamMembershipService = server.TeamMembershipService
|
||||||
storidgeHandler.ProxyManager = proxyManager
|
storidgeHandler.ProxyManager = proxyManager
|
||||||
|
|
||||||
|
@ -111,6 +131,7 @@ func (server *Server) Start() error {
|
||||||
TeamHandler: teamHandler,
|
TeamHandler: teamHandler,
|
||||||
TeamMembershipHandler: teamMembershipHandler,
|
TeamMembershipHandler: teamMembershipHandler,
|
||||||
EndpointHandler: endpointHandler,
|
EndpointHandler: endpointHandler,
|
||||||
|
EndpointGroupHandler: endpointGroupHandler,
|
||||||
RegistryHandler: registryHandler,
|
RegistryHandler: registryHandler,
|
||||||
DockerHubHandler: dockerHubHandler,
|
DockerHubHandler: dockerHubHandler,
|
||||||
ResourceHandler: resourceHandler,
|
ResourceHandler: resourceHandler,
|
||||||
|
|
|
@ -24,6 +24,7 @@ type (
|
||||||
NoAnalytics *bool
|
NoAnalytics *bool
|
||||||
Templates *string
|
Templates *string
|
||||||
TLSVerify *bool
|
TLSVerify *bool
|
||||||
|
TLSSkipVerify *bool
|
||||||
TLSCacert *string
|
TLSCacert *string
|
||||||
TLSCert *string
|
TLSCert *string
|
||||||
TLSKey *string
|
TLSKey *string
|
||||||
|
@ -168,12 +169,17 @@ type (
|
||||||
// EndpointID represents an endpoint identifier.
|
// EndpointID represents an endpoint identifier.
|
||||||
EndpointID int
|
EndpointID int
|
||||||
|
|
||||||
|
// EndpointType represents the type of an endpoint.
|
||||||
|
EndpointType int
|
||||||
|
|
||||||
// Endpoint represents a Docker endpoint with all the info required
|
// Endpoint represents a Docker endpoint with all the info required
|
||||||
// to connect to it.
|
// to connect to it.
|
||||||
Endpoint struct {
|
Endpoint struct {
|
||||||
ID EndpointID `json:"Id"`
|
ID EndpointID `json:"Id"`
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
|
Type EndpointType `json:"Type"`
|
||||||
URL string `json:"URL"`
|
URL string `json:"URL"`
|
||||||
|
GroupID EndpointGroupID `json:"GroupId"`
|
||||||
PublicURL string `json:"PublicURL"`
|
PublicURL string `json:"PublicURL"`
|
||||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||||
|
@ -188,6 +194,19 @@ type (
|
||||||
TLSKeyPath string `json:"TLSKey,omitempty"`
|
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 represents a extension associated to an endpoint.
|
||||||
EndpointExtension struct {
|
EndpointExtension struct {
|
||||||
Type EndpointExtensionType `json:"Type"`
|
Type EndpointExtensionType `json:"Type"`
|
||||||
|
@ -248,6 +267,7 @@ type (
|
||||||
// DataStore defines the interface to manage the data.
|
// DataStore defines the interface to manage the data.
|
||||||
DataStore interface {
|
DataStore interface {
|
||||||
Open() error
|
Open() error
|
||||||
|
Init() error
|
||||||
Close() error
|
Close() error
|
||||||
MigrateData() error
|
MigrateData() error
|
||||||
}
|
}
|
||||||
|
@ -301,6 +321,15 @@ type (
|
||||||
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
|
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 represents a service for managing registry data.
|
||||||
RegistryService interface {
|
RegistryService interface {
|
||||||
Registry(ID RegistryID) (*Registry, error)
|
Registry(ID RegistryID) (*Registry, error)
|
||||||
|
@ -354,6 +383,15 @@ type (
|
||||||
CompareHashAndData(hash string, data string) error
|
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 represents a service for managing JWT tokens.
|
||||||
JWTService interface {
|
JWTService interface {
|
||||||
GenerateToken(data *TokenData) (string, error)
|
GenerateToken(data *TokenData) (string, error)
|
||||||
|
@ -371,6 +409,10 @@ type (
|
||||||
GetStackProjectPath(stackIdentifier string) string
|
GetStackProjectPath(stackIdentifier string) string
|
||||||
StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error)
|
StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error)
|
||||||
StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (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.
|
// GitService represents a service for managing Git.
|
||||||
|
@ -401,11 +443,22 @@ type (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// APIVersion is the version number of the Portainer API.
|
// 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 is the version number of the Portainer database.
|
||||||
DBVersion = 8
|
DBVersion = 10
|
||||||
// DefaultTemplatesURL represents the default URL for the templates definitions.
|
// DefaultTemplatesURL represents the default URL for the templates definitions.
|
||||||
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
|
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 (
|
const (
|
||||||
|
@ -470,3 +523,11 @@ const (
|
||||||
// StoridgeEndpointExtension represents the Storidge extension
|
// StoridgeEndpointExtension represents the Storidge extension
|
||||||
StoridgeEndpointExtension
|
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
|
||||||
|
)
|
||||||
|
|
|
@ -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).
|
**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"
|
title: "Portainer API"
|
||||||
contact:
|
contact:
|
||||||
email: "info@portainer.io"
|
email: "info@portainer.io"
|
||||||
|
@ -224,7 +224,7 @@ paths:
|
||||||
**Access policy**: administrator
|
**Access policy**: administrator
|
||||||
operationId: "EndpointCreate"
|
operationId: "EndpointCreate"
|
||||||
consumes:
|
consumes:
|
||||||
- "application/json"
|
- "multipart/form-data"
|
||||||
produces:
|
produces:
|
||||||
- "application/json"
|
- "application/json"
|
||||||
parameters:
|
parameters:
|
||||||
|
@ -238,7 +238,7 @@ paths:
|
||||||
200:
|
200:
|
||||||
description: "Success"
|
description: "Success"
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/definitions/EndpointCreateResponse"
|
$ref: "#/definitions/Endpoint"
|
||||||
400:
|
400:
|
||||||
description: "Invalid request"
|
description: "Invalid request"
|
||||||
schema:
|
schema:
|
||||||
|
@ -2143,7 +2143,7 @@ definitions:
|
||||||
description: "Is analytics enabled"
|
description: "Is analytics enabled"
|
||||||
Version:
|
Version:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "1.16.5"
|
example: "1.17.0"
|
||||||
description: "Portainer API version"
|
description: "Portainer API version"
|
||||||
PublicSettingsInspectResponse:
|
PublicSettingsInspectResponse:
|
||||||
type: "object"
|
type: "object"
|
||||||
|
@ -2455,6 +2455,15 @@ definitions:
|
||||||
type: "boolean"
|
type: "boolean"
|
||||||
example: false
|
example: false
|
||||||
description: "Skip client verification when using TLS"
|
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:
|
EndpointCreateResponse:
|
||||||
type: "object"
|
type: "object"
|
||||||
properties:
|
properties:
|
||||||
|
@ -2603,12 +2612,13 @@ definitions:
|
||||||
ResourceID:
|
ResourceID:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08"
|
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:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "container"
|
example: "container"
|
||||||
description: "Type of Docker resource. Valid values are: container, volume\
|
description: "Type of Docker resource. Valid values are: container, volume\
|
||||||
\ or service"
|
\ service, secret, config or stack"
|
||||||
AdministratorsOnly:
|
AdministratorsOnly:
|
||||||
type: "boolean"
|
type: "boolean"
|
||||||
example: true
|
example: true
|
||||||
|
@ -2923,7 +2933,7 @@ definitions:
|
||||||
RepositoryPassword:
|
RepositoryPassword:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "myGitPassword"
|
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:
|
Env:
|
||||||
type: "array"
|
type: "array"
|
||||||
description: "A list of environment variables used during stack deployment"
|
description: "A list of environment variables used during stack deployment"
|
||||||
|
|
|
@ -17,6 +17,7 @@ angular.module('portainer', [
|
||||||
'luegg.directives',
|
'luegg.directives',
|
||||||
'portainer.templates',
|
'portainer.templates',
|
||||||
'portainer.app',
|
'portainer.app',
|
||||||
|
'portainer.agent',
|
||||||
'portainer.docker',
|
'portainer.docker',
|
||||||
'extension.storidge',
|
'extension.storidge',
|
||||||
'rzModule']);
|
'rzModule']);
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
angular.module('portainer.agent', []);
|
|
@ -0,0 +1,7 @@
|
||||||
|
angular.module('portainer.agent').component('nodeSelector', {
|
||||||
|
templateUrl: 'app/agent/components/node-selector/nodeSelector.html',
|
||||||
|
controller: 'NodeSelectorController',
|
||||||
|
bindings: {
|
||||||
|
model: '='
|
||||||
|
}
|
||||||
|
});
|
|
@ -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>
|
|
@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
}]);
|
|
@ -0,0 +1,5 @@
|
||||||
|
function AgentViewModel(data) {
|
||||||
|
this.IPAddress = data.IPAddress;
|
||||||
|
this.NodeName = data.NodeName;
|
||||||
|
this.NodeRole = data.NodeRole;
|
||||||
|
}
|
|
@ -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}
|
||||||
|
});
|
||||||
|
}]);
|
|
@ -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;
|
||||||
|
}]);
|
|
@ -1,5 +1,6 @@
|
||||||
angular.module('portainer')
|
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';
|
'use strict';
|
||||||
|
|
||||||
EndpointProvider.initialize();
|
EndpointProvider.initialize();
|
||||||
|
@ -27,6 +28,10 @@ angular.module('portainer')
|
||||||
originalSet.apply(cfpLoadingBar, arguments);
|
originalSet.apply(cfpLoadingBar, arguments);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$transitions.onBefore({ to: 'docker.**' }, function() {
|
||||||
|
HttpRequestHelper.resetAgentTargetQueue();
|
||||||
|
});
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,20 @@ angular.module('portainer')
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
$httpProvider.interceptors.push('jwtInterceptor');
|
$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.setAccount('@@CONFIG_GA_ID');
|
||||||
AnalyticsProvider.startOffline(true);
|
AnalyticsProvider.startOffline(true);
|
||||||
|
|
|
@ -2,6 +2,7 @@ angular.module('portainer')
|
||||||
.constant('API_ENDPOINT_AUTH', 'api/auth')
|
.constant('API_ENDPOINT_AUTH', 'api/auth')
|
||||||
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
|
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
|
||||||
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
|
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
|
||||||
|
.constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups')
|
||||||
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
|
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
|
||||||
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
|
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
|
||||||
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
|
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
|
||||||
|
|
|
@ -57,7 +57,7 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
|
|
||||||
var container = {
|
var container = {
|
||||||
name: 'docker.containers.container',
|
name: 'docker.containers.container',
|
||||||
url: '/:id',
|
url: '/:id?nodeName',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: 'app/docker/views/containers/edit/container.html',
|
templateUrl: 'app/docker/views/containers/edit/container.html',
|
||||||
|
@ -79,15 +79,12 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
|
|
||||||
var containerCreation = {
|
var containerCreation = {
|
||||||
name: 'docker.containers.new',
|
name: 'docker.containers.new',
|
||||||
url: '/new',
|
url: '/new?nodeName&from',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: 'app/docker/views/containers/create/createcontainer.html',
|
templateUrl: 'app/docker/views/containers/create/createcontainer.html',
|
||||||
controller: 'CreateContainerController'
|
controller: 'CreateContainerController'
|
||||||
}
|
}
|
||||||
},
|
|
||||||
params: {
|
|
||||||
from: ''
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -170,7 +167,7 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
|
|
||||||
var image = {
|
var image = {
|
||||||
name: 'docker.images.image',
|
name: 'docker.images.image',
|
||||||
url: '/:id',
|
url: '/:id?nodeName',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: 'app/docker/views/images/edit/image.html',
|
templateUrl: 'app/docker/views/images/edit/image.html',
|
||||||
|
@ -203,7 +200,7 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
|
|
||||||
var network = {
|
var network = {
|
||||||
name: 'docker.networks.network',
|
name: 'docker.networks.network',
|
||||||
url: '/:id',
|
url: '/:id?nodeName',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: 'app/docker/views/networks/edit/network.html',
|
templateUrl: 'app/docker/views/networks/edit/network.html',
|
||||||
|
@ -443,7 +440,7 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
|
|
||||||
var volume = {
|
var volume = {
|
||||||
name: 'docker.volumes.volume',
|
name: 'docker.volumes.volume',
|
||||||
url: '/:id',
|
url: '/:id?nodeName',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: 'app/docker/views/volumes/edit/volume.html',
|
templateUrl: 'app/docker/views/volumes/edit/volume.html',
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
angular.module('portainer.docker').component('dashboardClusterAgentInfo', {
|
||||||
|
templateUrl: 'app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html',
|
||||||
|
controller: 'DashboardClusterAgentInfoController'
|
||||||
|
});
|
|
@ -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>
|
|
@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
}]);
|
|
@ -38,7 +38,7 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr dir-paginate="(key, value) in $ctrl.dataset | itemsPerPage: $ctrl.state.paginatedItemLimit" ng-class="{active: item.Checked}">
|
<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.IPAddress || '-' }}</td>
|
||||||
<td>{{ value.Gateway || '-' }}</td>
|
<td>{{ value.Gateway || '-' }}</td>
|
||||||
<td>{{ value.MacAddress || '-' }}</td>
|
<td>{{ value.MacAddress || '-' }}</td>
|
||||||
|
|
|
@ -11,6 +11,7 @@ angular.module('portainer.docker').component('containerNetworksDatatable', {
|
||||||
joinNetworkAction: '<',
|
joinNetworkAction: '<',
|
||||||
joinNetworkActionInProgress: '<',
|
joinNetworkActionInProgress: '<',
|
||||||
leaveNetworkActionInProgress: '<',
|
leaveNetworkActionInProgress: '<',
|
||||||
leaveNetworkAction: '<'
|
leaveNetworkAction: '<',
|
||||||
|
nodeName: '<'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -156,11 +156,11 @@
|
||||||
<i class="fa fa-sort-alpha-up" 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>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th ng-if="$ctrl.swarmContainers">
|
<th ng-if="$ctrl.showHostColumn">
|
||||||
<a ng-click="$ctrl.changeOrderBy('Host')">
|
<a ng-click="$ctrl.changeOrderBy('NodeName')">
|
||||||
Host IP
|
Host
|
||||||
<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-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 === 'Host' && $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>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
|
@ -186,8 +186,7 @@
|
||||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||||
<label for="select_{{ $index }}"></label>
|
<label for="select_{{ $index }}"></label>
|
||||||
</span>
|
</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, nodeName: item.NodeName })">{{ 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>
|
|
||||||
</td>
|
</td>
|
||||||
<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>
|
<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>
|
||||||
<td ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect">
|
<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;">
|
<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.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})" title="Logs"><i class="fa fa-file-alt 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})" title="Console"><i class="fa fa-terminal 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})" title="Inspect"><i class="fa fa-info-circle 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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.StackName ? item.StackName : '-' }}</td>
|
<td>{{ item.StackName ? item.StackName : '-' }}</td>
|
||||||
<td><a ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a></td>
|
<td><a ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a></td>
|
||||||
<td>{{ item.IP ? item.IP : '-' }}</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>
|
<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">
|
<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 }}
|
<i class="fa fa-external-link-alt" aria-hidden="true"></i> {{ p.public }}:{{ p.private }}
|
||||||
|
@ -219,10 +218,10 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="!$ctrl.dataset">
|
<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>
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -10,7 +10,7 @@ angular.module('portainer.docker').component('containersDatatable', {
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
showTextFilter: '<',
|
||||||
showOwnershipColumn: '<',
|
showOwnershipColumn: '<',
|
||||||
swarmContainers: '<',
|
showHostColumn: '<',
|
||||||
publicUrl: '<',
|
publicUrl: '<',
|
||||||
containerNameTruncateSize: '<',
|
containerNameTruncateSize: '<',
|
||||||
startAction: '<',
|
startAction: '<',
|
||||||
|
|
|
@ -93,6 +93,13 @@
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -102,7 +109,7 @@
|
||||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||||
<label for="select_{{ $index }}"></label>
|
<label for="select_{{ $index }}"></label>
|
||||||
</span>
|
</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>
|
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="::item.ContainerCount === 0">Unused</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -110,12 +117,13 @@
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.VirtualSize | humansize }}</td>
|
<td>{{ item.VirtualSize | humansize }}</td>
|
||||||
<td>{{ item.Created | getisodatefromtimestamp }}</td>
|
<td>{{ item.Created | getisodatefromtimestamp }}</td>
|
||||||
|
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="!$ctrl.dataset">
|
<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>
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -9,6 +9,7 @@ angular.module('portainer.docker').component('imagesDatatable', {
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
showTextFilter: '<',
|
||||||
|
showHostColumn: '<',
|
||||||
removeAction: '<',
|
removeAction: '<',
|
||||||
forceRemoveAction: '<'
|
forceRemoveAction: '<'
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IPAM.Config[0].Gateway' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</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">
|
<th ng-if="$ctrl.showOwnershipColumn">
|
||||||
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
|
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
|
||||||
Ownership
|
Ownership
|
||||||
|
@ -97,7 +104,7 @@
|
||||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||||
<label for="select_{{ $index }}"></label>
|
<label for="select_{{ $index }}"></label>
|
||||||
</span>
|
</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>
|
||||||
<td>{{ item.StackName ? item.StackName : '-' }}</td>
|
<td>{{ item.StackName ? item.StackName : '-' }}</td>
|
||||||
<td>{{ item.Scope }}</td>
|
<td>{{ item.Scope }}</td>
|
||||||
|
@ -105,6 +112,7 @@
|
||||||
<td>{{ item.IPAM.Driver }}</td>
|
<td>{{ item.IPAM.Driver }}</td>
|
||||||
<td>{{ item.IPAM.Config[0].Subnet ? item.IPAM.Config[0].Subnet : '-' }}</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>{{ 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">
|
<td ng-if="$ctrl.showOwnershipColumn">
|
||||||
<span>
|
<span>
|
||||||
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
||||||
|
@ -113,10 +121,10 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="!$ctrl.dataset">
|
<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>
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -10,6 +10,7 @@ angular.module('portainer.docker').component('networksDatatable', {
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
showTextFilter: '<',
|
||||||
showOwnershipColumn: '<',
|
showOwnershipColumn: '<',
|
||||||
|
showHostColumn: '<',
|
||||||
removeAction: '<'
|
removeAction: '<'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
|
|
@ -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: '<'
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -54,20 +54,26 @@
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Updated' && $ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Updated' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th ng-if="$ctrl.showLogsButton">Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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}">
|
<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><span class="label label-{{ item.Status.State | taskstatusbadge }}">{{ item.Status.State }}</span></td>
|
||||||
<td ng-if="$ctrl.showSlotColumn">{{ item.Slot ? item.Slot : '-' }}</td>
|
<td ng-if="$ctrl.showSlotColumn">{{ item.Slot ? item.Slot : '-' }}</td>
|
||||||
<td>{{ item.NodeId | tasknodename: $ctrl.nodes }}</td>
|
<td>{{ item.NodeId | tasknodename: $ctrl.nodes }}</td>
|
||||||
<td>{{ item.Updated | getisodate }}</td>
|
<td>{{ item.Updated | getisodate }}</td>
|
||||||
<td ng-if="$ctrl.showLogsButton">
|
<td>
|
||||||
<a ui-sref="docker.tasks.task.logs({id: item.Id})">
|
<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
|
<i class="fa fa-file-alt" aria-hidden="true"></i> View logs
|
||||||
</a>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="!$ctrl.dataset">
|
<tr ng-if="!$ctrl.dataset">
|
||||||
|
|
|
@ -11,6 +11,7 @@ angular.module('portainer.docker').component('tasksDatatable', {
|
||||||
nodes: '<',
|
nodes: '<',
|
||||||
showTextFilter: '<',
|
showTextFilter: '<',
|
||||||
showSlotColumn: '<',
|
showSlotColumn: '<',
|
||||||
showLogsButton: '<'
|
showLogsButton: '<',
|
||||||
|
agentProxy: '<'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -84,6 +84,13 @@
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Mountpoint' && $ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Mountpoint' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</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">
|
<th ng-if="$ctrl.showOwnershipColumn">
|
||||||
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
|
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
|
||||||
Ownership
|
Ownership
|
||||||
|
@ -100,12 +107,13 @@
|
||||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||||
<label for="select_{{ $index }}"></label>
|
<label for="select_{{ $index }}"></label>
|
||||||
</span>
|
</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>
|
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="item.dangling">Unused</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.StackName ? item.StackName : '-' }}</td>
|
<td>{{ item.StackName ? item.StackName : '-' }}</td>
|
||||||
<td>{{ item.Driver }}</td>
|
<td>{{ item.Driver }}</td>
|
||||||
<td>{{ item.Mountpoint | truncatelr }}</td>
|
<td>{{ item.Mountpoint | truncatelr }}</td>
|
||||||
|
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
|
||||||
<td ng-if="$ctrl.showOwnershipColumn">
|
<td ng-if="$ctrl.showOwnershipColumn">
|
||||||
<span>
|
<span>
|
||||||
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
||||||
|
@ -114,10 +122,10 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="!$ctrl.dataset">
|
<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>
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -10,6 +10,7 @@ angular.module('portainer.docker').component('volumesDatatable', {
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
showTextFilter: '<',
|
||||||
showOwnershipColumn: '<',
|
showOwnershipColumn: '<',
|
||||||
|
showHostColumn: '<',
|
||||||
removeAction: '<'
|
removeAction: '<'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -162,12 +162,6 @@ angular.module('portainer.docker')
|
||||||
return name.substring(1, name.length);
|
return name.substring(1, name.length);
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter('swarmcontainername', function () {
|
|
||||||
'use strict';
|
|
||||||
return function (container) {
|
|
||||||
return _.split(container.Names[0], '/')[2];
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter('swarmversion', function () {
|
.filter('swarmversion', function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
return function (text) {
|
return function (text) {
|
||||||
|
|
|
@ -1,36 +1,39 @@
|
||||||
angular.module('portainer.docker')
|
angular.module('portainer.docker')
|
||||||
.factory('InfoHelper', [function InfoHelperFactory() {
|
.factory('InfoHelper', [function InfoHelperFactory() {
|
||||||
'use strict';
|
'use strict';
|
||||||
return {
|
|
||||||
determineEndpointMode: function(info) {
|
var helper = {};
|
||||||
var mode = {
|
|
||||||
provider: '',
|
helper.determineEndpointMode = function(info, type) {
|
||||||
role: ''
|
var mode = {
|
||||||
};
|
provider: '',
|
||||||
if (_.startsWith(info.ServerVersion, 'swarm')) {
|
role: '',
|
||||||
mode.provider = 'DOCKER_SWARM';
|
agentProxy: false
|
||||||
if (info.SystemStatus[0][1] === 'primary') {
|
};
|
||||||
mode.role = 'PRIMARY';
|
|
||||||
} else {
|
if (type === 2) {
|
||||||
mode.role = 'REPLICA';
|
mode.provider = 'DOCKER_SWARM_MODE';
|
||||||
}
|
mode.role = 'MANAGER';
|
||||||
} else {
|
mode.agentProxy = true;
|
||||||
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 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;
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -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;
|
||||||
|
}]);
|
|
@ -11,6 +11,7 @@ angular.module('portainer.docker')
|
||||||
var task = tasks[i];
|
var task = tasks[i];
|
||||||
if (task.ServiceId === service.Id) {
|
if (task.ServiceId === service.Id) {
|
||||||
service.Tasks.push(task);
|
service.Tasks.push(task);
|
||||||
|
task.Service = service;
|
||||||
} else {
|
} else {
|
||||||
otherServicesTasks.push(task);
|
otherServicesTasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}]);
|
|
@ -34,6 +34,9 @@ function ContainerViewModel(data) {
|
||||||
if (data.Portainer.ResourceControl) {
|
if (data.Portainer.ResourceControl) {
|
||||||
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
||||||
}
|
}
|
||||||
|
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
|
||||||
|
this.NodeName = data.Portainer.Agent.NodeName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,10 @@ function ImageViewModel(data) {
|
||||||
this.RepoTags = data.RepoTags;
|
this.RepoTags = data.RepoTags;
|
||||||
this.VirtualSize = data.VirtualSize;
|
this.VirtualSize = data.VirtualSize;
|
||||||
this.ContainerCount = data.ContainerCount;
|
this.ContainerCount = data.ContainerCount;
|
||||||
|
|
||||||
|
if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) {
|
||||||
|
this.NodeName = data.Portainer.Agent.NodeName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageBuildModel(data) {
|
function ImageBuildModel(data) {
|
||||||
|
|
|
@ -19,5 +19,8 @@ function NetworkViewModel(data) {
|
||||||
if (data.Portainer.ResourceControl) {
|
if (data.Portainer.ResourceControl) {
|
||||||
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
||||||
}
|
}
|
||||||
|
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
|
||||||
|
this.NodeName = data.Portainer.Agent.NodeName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,4 +7,7 @@ function TaskViewModel(data) {
|
||||||
this.Status = data.Status;
|
this.Status = data.Status;
|
||||||
this.ServiceId = data.ServiceID;
|
this.ServiceId = data.ServiceID;
|
||||||
this.NodeId = data.NodeID;
|
this.NodeId = data.NodeID;
|
||||||
|
if (data.Status && data.Status.ContainerStatus && data.Status.ContainerStatus.ContainerID) {
|
||||||
|
this.ContainerId = data.Status.ContainerStatus.ContainerID;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
function TemplateViewModel(data) {
|
function TemplateViewModel(data) {
|
||||||
this.Type = data.type;
|
this.Type = data.type;
|
||||||
this.Name = data.name;
|
this.Name = data.name;
|
||||||
|
this.Hostname = data.hostname;
|
||||||
this.Title = data.title;
|
this.Title = data.title;
|
||||||
this.Description = data.description;
|
this.Description = data.description;
|
||||||
this.Note = data.note;
|
this.Note = data.note;
|
||||||
|
|
|
@ -14,5 +14,8 @@ function VolumeViewModel(data) {
|
||||||
if (data.Portainer.ResourceControl) {
|
if (data.Portainer.ResourceControl) {
|
||||||
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
||||||
}
|
}
|
||||||
|
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
|
||||||
|
this.NodeName = data.Portainer.Agent.NodeName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,38 @@
|
||||||
angular.module('portainer.docker')
|
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';
|
'use strict';
|
||||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/containers/:id/:action', {
|
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/containers/:id/:action', {
|
||||||
name: '@name',
|
name: '@name',
|
||||||
endpointId: EndpointProvider.endpointID
|
endpointId: EndpointProvider.endpointID
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: {method: 'GET', params: {all: 0, action: 'json', filters: '@filters' }, isArray: true},
|
query: {
|
||||||
get: {method: 'GET', params: {action: 'json'}},
|
method: 'GET', params: { all: 0, action: 'json', filters: '@filters' },
|
||||||
stop: {method: 'POST', params: {id: '@id', t: 5, action: 'stop'}},
|
isArray: true
|
||||||
restart: {method: 'POST', params: {id: '@id', t: 5, action: 'restart'}},
|
},
|
||||||
kill: {method: 'POST', params: {id: '@id', action: 'kill'}},
|
get: {
|
||||||
pause: {method: 'POST', params: {id: '@id', action: 'pause'}},
|
method: 'GET', params: { action: 'json' }
|
||||||
unpause: {method: 'POST', params: {id: '@id', action: 'unpause'}},
|
},
|
||||||
|
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: {
|
logs: {
|
||||||
method: 'GET', params: { id: '@id', action: 'logs' },
|
method: 'GET', params: { id: '@id', action: 'logs' },
|
||||||
timeout: 4500, ignoreLoadingBar: true,
|
timeout: 4500, ignoreLoadingBar: true,
|
||||||
transformResponse: logsHandler, isArray: true
|
transformResponse: logsHandler
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
method: 'GET', params: { id: '@id', stream: false, action: 'stats' },
|
method: 'GET', params: { id: '@id', stream: false, action: 'stats' },
|
||||||
|
@ -40,7 +56,7 @@ angular.module('portainer.docker')
|
||||||
transformResponse: genericHandler
|
transformResponse: genericHandler
|
||||||
},
|
},
|
||||||
rename: {
|
rename: {
|
||||||
method: 'POST', params: {id: '@id', action: 'rename', name: '@name'},
|
method: 'POST', params: { id: '@id', action: 'rename', name: '@name' },
|
||||||
transformResponse: genericHandler
|
transformResponse: genericHandler
|
||||||
},
|
},
|
||||||
exec: {
|
exec: {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
angular.module('portainer.docker')
|
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';
|
'use strict';
|
||||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/exec/:id/:action', {
|
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/exec/:id/:action', {
|
||||||
endpointId: EndpointProvider.endpointID
|
endpointId: EndpointProvider.endpointID
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
angular.module('portainer.docker')
|
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';
|
'use strict';
|
||||||
|
|
||||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/images/:id/:action', {
|
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/images/:id/:action', {
|
||||||
|
|
|
@ -1,16 +1,30 @@
|
||||||
angular.module('portainer.docker')
|
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';
|
'use strict';
|
||||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', {
|
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', {
|
||||||
id: '@id',
|
id: '@id',
|
||||||
endpointId: EndpointProvider.endpointID
|
endpointId: EndpointProvider.endpointID
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: {method: 'GET', isArray: true},
|
query: {
|
||||||
get: {method: 'GET'},
|
method: 'GET', isArray: true
|
||||||
create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler, ignoreLoadingBar: true},
|
},
|
||||||
remove: { method: 'DELETE', transformResponse: genericHandler },
|
get: {
|
||||||
connect: {method: 'POST', params: {action: 'connect'}},
|
method: 'GET'
|
||||||
disconnect: {method: 'POST', params: {action: 'disconnect'}}
|
},
|
||||||
|
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' }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -45,15 +45,11 @@ function genericHandler(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Docker API returns the logs as a single string.
|
// The Docker API returns the logs as a single string.
|
||||||
// This handler will return an array with each line being an entry.
|
// This handler wraps the data in a JSON object under the "logs" property.
|
||||||
// It will also strip the 8 first characters of each line and remove any ANSI code related character sequences.
|
|
||||||
function logsHandler(data) {
|
function logsHandler(data) {
|
||||||
var logs = data;
|
return {
|
||||||
logs = logs.substring(8);
|
logs: data
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image delete API returns an array on success (Docker 1.9 -> Docker 1.12).
|
// Image delete API returns an array on success (Docker 1.9 -> Docker 1.12).
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
angular.module('portainer.docker')
|
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';
|
'use strict';
|
||||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/services/:id/:action', {
|
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/services/:id/:action', {
|
||||||
endpointId: EndpointProvider.endpointID
|
endpointId: EndpointProvider.endpointID
|
||||||
|
@ -17,7 +18,7 @@ angular.module('portainer.docker')
|
||||||
logs: {
|
logs: {
|
||||||
method: 'GET', params: { id: '@id', action: 'logs' },
|
method: 'GET', params: { id: '@id', action: 'logs' },
|
||||||
timeout: 4500, ignoreLoadingBar: true,
|
timeout: 4500, ignoreLoadingBar: true,
|
||||||
transformResponse: logsHandler, isArray: true
|
transformResponse: logsHandler
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -6,7 +6,10 @@ angular.module('portainer.docker')
|
||||||
endpointId: EndpointProvider.endpointID
|
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 },
|
version: { method: 'GET', params: { action: 'version' }, ignoreLoadingBar: true, timeout: 4500 },
|
||||||
events: {
|
events: {
|
||||||
method: 'GET', params: { action: 'events', since: '@since', until: '@until' },
|
method: 'GET', params: { action: 'events', since: '@since', until: '@until' },
|
||||||
|
|
|
@ -10,7 +10,7 @@ angular.module('portainer.docker')
|
||||||
logs: {
|
logs: {
|
||||||
method: 'GET', params: { id: '@id', action: 'logs' },
|
method: 'GET', params: { id: '@id', action: 'logs' },
|
||||||
timeout: 4500, ignoreLoadingBar: true,
|
timeout: 4500, ignoreLoadingBar: true,
|
||||||
transformResponse: logsHandler, isArray: true
|
transformResponse: logsHandler
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
angular.module('portainer.docker')
|
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';
|
'use strict';
|
||||||
var service = {};
|
var service = {};
|
||||||
|
|
||||||
|
@ -34,6 +35,34 @@ angular.module('portainer.docker')
|
||||||
return deferred.promise;
|
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) {
|
service.createContainer = function(configuration) {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
Container.create(configuration).$promise
|
Container.create(configuration).$promise
|
||||||
|
@ -50,30 +79,6 @@ angular.module('portainer.docker')
|
||||||
return deferred.promise;
|
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) {
|
service.createAndStartContainer = function(configuration) {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
var containerID;
|
var containerID;
|
||||||
|
@ -94,7 +99,7 @@ angular.module('portainer.docker')
|
||||||
service.remove = function(container, removeVolumes) {
|
service.remove = function(container, removeVolumes) {
|
||||||
var deferred = $q.defer();
|
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) {
|
.then(function success(data) {
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
deferred.reject({ msg: data.message, err: data.message });
|
deferred.reject({ msg: data.message, err: data.message });
|
||||||
|
@ -116,7 +121,7 @@ angular.module('portainer.docker')
|
||||||
service.createExec = function(execConfig) {
|
service.createExec = function(execConfig) {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
|
|
||||||
Container.exec(execConfig).$promise
|
Container.exec({}, execConfig).$promise
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
deferred.reject({ msg: data.message, err: data.message });
|
deferred.reject({ msg: data.message, err: data.message });
|
||||||
|
@ -131,7 +136,9 @@ angular.module('portainer.docker')
|
||||||
return deferred.promise;
|
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 = {
|
var parameters = {
|
||||||
id: id,
|
id: id,
|
||||||
stdout: stdout || 0,
|
stdout: stdout || 0,
|
||||||
|
@ -140,13 +147,22 @@ angular.module('portainer.docker')
|
||||||
tail: tail || 'all'
|
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) {
|
service.containerStats = function(id) {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
|
|
||||||
Container.stats({id: id}).$promise
|
Container.stats({ id: id }).$promise
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
var containerStats = new ContainerStatsViewModel(data);
|
var containerStats = new ContainerStatsViewModel(data);
|
||||||
deferred.resolve(containerStats);
|
deferred.resolve(containerStats);
|
||||||
|
@ -159,11 +175,11 @@ angular.module('portainer.docker')
|
||||||
};
|
};
|
||||||
|
|
||||||
service.containerTop = function(id) {
|
service.containerTop = function(id) {
|
||||||
return Container.top({id: id}).$promise;
|
return Container.top({ id: id }).$promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.inspect = function(id) {
|
service.inspect = function(id) {
|
||||||
return Container.inspect({id: id}).$promise;
|
return Container.inspect({ id: id }).$promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
return service;
|
return service;
|
||||||
|
|
|
@ -7,7 +7,7 @@ angular.module('portainer.docker')
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
|
|
||||||
$timeout(function() {
|
$timeout(function() {
|
||||||
Exec.resize({id: execId, height: height, width: width}).$promise
|
Exec.resize({}, { id: execId, height: height, width: width }).$promise
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
deferred.reject({ msg: 'Unable to exec into container', err: data.message });
|
deferred.reject({ msg: 'Unable to exec into container', err: data.message });
|
||||||
|
|
|
@ -19,7 +19,7 @@ angular.module('portainer.docker')
|
||||||
service.network = function(id) {
|
service.network = function(id) {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
|
|
||||||
Network.get({id: id}).$promise
|
Network.get({ id: id }).$promise
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
var network = new NetworkViewModel(data);
|
var network = new NetworkViewModel(data);
|
||||||
deferred.resolve(network);
|
deferred.resolve(network);
|
||||||
|
@ -31,7 +31,7 @@ angular.module('portainer.docker')
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.networks = function(localNetworks, swarmNetworks, swarmAttachableNetworks, globalNetworks) {
|
service.networks = function(localNetworks, swarmNetworks, swarmAttachableNetworks) {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
|
|
||||||
Network.query({}).$promise
|
Network.query({}).$promise
|
||||||
|
@ -48,9 +48,6 @@ angular.module('portainer.docker')
|
||||||
if (swarmAttachableNetworks && network.Scope === 'swarm' && network.Attachable === true) {
|
if (swarmAttachableNetworks && network.Scope === 'swarm' && network.Attachable === true) {
|
||||||
return network;
|
return network;
|
||||||
}
|
}
|
||||||
if (globalNetworks && network.Scope === 'global') {
|
|
||||||
return network;
|
|
||||||
}
|
|
||||||
}).map(function (item) {
|
}).map(function (item) {
|
||||||
return new NetworkViewModel(item);
|
return new NetworkViewModel(item);
|
||||||
});
|
});
|
||||||
|
@ -68,5 +65,13 @@ angular.module('portainer.docker')
|
||||||
return Network.remove({ id: id }).$promise;
|
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;
|
return service;
|
||||||
}]);
|
}]);
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue