Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19bb83ba2a | |||
| f75c87315e | |||
| a0a667053e | |||
| b2b1c86067 | |||
| 74c92c4da8 | |||
| 7754933470 | |||
| 1c06bfd911 | |||
| 3b14e6b6b9 | |||
| a83ea1554c | |||
| 4d79259748 | |||
| cdb09a91a7 | |||
| 284f2b7752 | |||
| 55a96767bb | |||
| 6360e6a20b | |||
| 2327d696e0 | |||
| 77a85bd385 | |||
| e0cf088428 | |||
| 1e55ada6af | |||
| e8744e8c0b | |||
| 1162549209 | |||
| 2ffcb946b1 | |||
| 1d24a827de | |||
| c705d27ac6 | |||
| dea5038c93 | |||
| f0317d6d87 | |||
| afa3fd9a47 | |||
| fe74f36f62 | |||
| 05d6abf57b | |||
| 031b428e0c | |||
| 23f4939ee7 | |||
| 7690ef3c33 | |||
| 4f0e752d00 | |||
| 2a9ba1f9a2 | |||
| 216d6c2b14 | |||
| dca1976252 | |||
| 1cfbec557c | |||
| 517f983ec6 | |||
| 0edcdbd612 | |||
| a8ee774cf2 | |||
| 81ed0e4507 | |||
| 8d32703456 | |||
| eca39b11a8 | |||
| b2b685ba6f | |||
| 7e26d09881 | |||
| 80a23b5351 | |||
| 30dfd3d616 | |||
| c267f8bf57 | |||
| bca8936faa | |||
| a72ffe4188 | |||
| 27dcd708a6 | |||
| adf1ba7b47 | |||
| 50ece68f35 | |||
| 4e38e4ba33 | |||
| f0621cb09c | |||
| 9e47aedbe6 | |||
| 706490db5e | |||
| d34b1d5f9d | |||
| 66f29dd103 | |||
| 96e77b3ada | |||
| 3d9a3f11e4 | |||
| 9c277733d5 | |||
| ec2a9e149b | |||
| aa41fd02ef | |||
| 28c73323bf | |||
| b389e3c65a | |||
| 02b3d54a75 | |||
| f1a21c07bd | |||
| 403de0d319 | |||
| a76ccff7c9 | |||
| 1ae9832980 | |||
| 8a9619c7e8 | |||
| 9634cf1563 | |||
| 716cd033b2 | |||
| 28bca85e01 | |||
| 73e6498d2f | |||
| 1b8d5e89d1 | |||
| 76aeee7237 | |||
| b9a1c68ea0 | |||
| b8f8df5f48 | |||
| 0c5152fb5f | |||
| 81de2a5afb | |||
| e065bd4a47 | |||
| 9b80b6adb2 | |||
| eb43579378 | |||
| b5e256c967 | |||
| ae5416583e | |||
| 5b9cb1a883 | |||
| b040b3ff8c | |||
| 3ff49542f3 | |||
| 27dcfd043b | |||
| 1de0619fd5 | |||
| 1c67db0c70 | |||
| 7365e69c59 | |||
| 23a565243a | |||
| 27dceadba1 | |||
| 6f471cef34 | |||
| e6422a6d75 | |||
| 56cab429de | |||
| 5f742c2163 | |||
| f31f29fa2f | |||
| 672819f3af | |||
| 0ff0c3ed0d | |||
| 54750f002a | |||
| 4c2dfb3346 | |||
| 8ae3abf29e | |||
| 362f036a68 | |||
| 0d0072a50e | |||
| 173ea372c2 | |||
| 8c75f705e2 | |||
| b1863430df | |||
| c51db23c32 |
@@ -17,10 +17,17 @@ steps:
|
||||
- yarn grunt build-webapp
|
||||
- mv api/cmd/portainer/portainer dist/
|
||||
|
||||
get_docker_version:
|
||||
image: alpine
|
||||
working_directory: ${{build_frontend}}
|
||||
commands:
|
||||
- cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2`
|
||||
|
||||
download_docker_binary:
|
||||
image: busybox
|
||||
working_directory: ${{build_frontend}}
|
||||
commands:
|
||||
- echo ${{DOCKER_VERSION}}
|
||||
- wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz
|
||||
- tar -xf /tmp/docker-binaries.tgz -C /tmp
|
||||
- mv /tmp/docker/docker dist/
|
||||
@@ -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.
|
||||
@@ -30,9 +30,6 @@ You can have a use Github filters to list these issues:
|
||||
* intermediate labeled issues: https://github.com/portainer/portainer/labels/exp%2Fintermediate
|
||||
* advanced labeled issues: https://github.com/portainer/portainer/labels/exp%2Fadvanced
|
||||
|
||||
### Linting
|
||||
|
||||
Please check your code using `grunt lint` before submitting your pull requests.
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
)
|
||||
|
||||
// TarFileInBuffer will create a tar archive containing a single file named via fileName and using the content
|
||||
// specified in fileContent. Returns the archive as a byte array.
|
||||
func TarFileInBuffer(fileContent []byte, fileName string) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
tarWriter := tar.NewWriter(&buffer)
|
||||
|
||||
header := &tar.Header{
|
||||
Name: fileName,
|
||||
Mode: 0600,
|
||||
Size: int64(len(fileContent)),
|
||||
}
|
||||
|
||||
err := tarWriter.WriteHeader(header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tarWriter.Write(fileContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tarWriter.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
+27
-1
@@ -20,6 +20,7 @@ type Store struct {
|
||||
TeamService *TeamService
|
||||
TeamMembershipService *TeamMembershipService
|
||||
EndpointService *EndpointService
|
||||
EndpointGroupService *EndpointGroupService
|
||||
ResourceControlService *ResourceControlService
|
||||
VersionService *VersionService
|
||||
SettingsService *SettingsService
|
||||
@@ -38,6 +39,7 @@ const (
|
||||
teamBucketName = "teams"
|
||||
teamMembershipBucketName = "team_membership"
|
||||
endpointBucketName = "endpoints"
|
||||
endpointGroupBucketName = "endpoint_groups"
|
||||
resourceControlBucketName = "resource_control"
|
||||
settingsBucketName = "settings"
|
||||
registryBucketName = "registries"
|
||||
@@ -53,6 +55,7 @@ func NewStore(storePath string) (*Store, error) {
|
||||
TeamService: &TeamService{},
|
||||
TeamMembershipService: &TeamMembershipService{},
|
||||
EndpointService: &EndpointService{},
|
||||
EndpointGroupService: &EndpointGroupService{},
|
||||
ResourceControlService: &ResourceControlService{},
|
||||
VersionService: &VersionService{},
|
||||
SettingsService: &SettingsService{},
|
||||
@@ -64,6 +67,7 @@ func NewStore(storePath string) (*Store, error) {
|
||||
store.TeamService.store = store
|
||||
store.TeamMembershipService.store = store
|
||||
store.EndpointService.store = store
|
||||
store.EndpointGroupService.store = store
|
||||
store.ResourceControlService.store = store
|
||||
store.VersionService.store = store
|
||||
store.SettingsService.store = store
|
||||
@@ -94,7 +98,7 @@ func (store *Store) Open() error {
|
||||
store.db = db
|
||||
|
||||
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
|
||||
resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
|
||||
endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
|
||||
registryBucketName, dockerhubBucketName, stackBucketName}
|
||||
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
@@ -110,6 +114,28 @@ func (store *Store) Open() error {
|
||||
})
|
||||
}
|
||||
|
||||
// Init creates the default data set.
|
||||
func (store *Store) Init() error {
|
||||
groups, err := store.EndpointGroupService.EndpointGroups()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(groups) == 0 {
|
||||
unassignedGroup := &portainer.EndpointGroup{
|
||||
Name: "Unassigned",
|
||||
Description: "Unassigned endpoints",
|
||||
Labels: []portainer.Pair{},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
}
|
||||
|
||||
return store.EndpointGroupService.CreateEndpointGroup(unassignedGroup)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the BoltDB database.
|
||||
func (store *Store) Close() error {
|
||||
if store.db != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// MarshalEndpointGroup encodes an endpoint group to binary format.
|
||||
func MarshalEndpointGroup(group *portainer.EndpointGroup) ([]byte, error) {
|
||||
return json.Marshal(group)
|
||||
}
|
||||
|
||||
// UnmarshalEndpointGroup decodes an endpoint group from a binary data.
|
||||
func UnmarshalEndpointGroup(data []byte, group *portainer.EndpointGroup) error {
|
||||
return json.Unmarshal(data, group)
|
||||
}
|
||||
|
||||
// MarshalStack encodes a stack to binary format.
|
||||
func MarshalStack(stack *portainer.Stack) ([]byte, error) {
|
||||
return json.Marshal(stack)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package bolt
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
func (m *Migrator) updateEndpointsToVersion8() error {
|
||||
legacyEndpoints, err := m.EndpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range legacyEndpoints {
|
||||
endpoint.Extensions = []portainer.EndpointExtension{}
|
||||
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) 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
|
||||
}
|
||||
@@ -89,6 +89,29 @@ func (m *Migrator) Migrate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if m.CurrentDBVersion < 8 {
|
||||
err := m.updateEndpointsToVersion8()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https: //github.com/portainer/portainer/issues/1396
|
||||
if m.CurrentDBVersion < 9 {
|
||||
err := m.updateEndpointsToVersion9()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/461
|
||||
if m.CurrentDBVersion < 10 {
|
||||
err := m.updateEndpointsToVersion10()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+4
-20
@@ -1,7 +1,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
@@ -33,13 +32,13 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
flags := &portainer.CLIFlags{
|
||||
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
|
||||
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
||||
CheckHealth: kingpin.Flag("check-health", "GET http://localhost:<port>/api/health endpoint").Default(defaultCheckHealth).Short('c').Bool(),
|
||||
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
||||
Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(),
|
||||
ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(),
|
||||
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
|
||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(),
|
||||
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(),
|
||||
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
|
||||
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
|
||||
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
|
||||
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),
|
||||
@@ -49,10 +48,9 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(),
|
||||
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
|
||||
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
|
||||
// Deprecated flags
|
||||
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
|
||||
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
|
||||
Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Short('t').String(),
|
||||
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
|
||||
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
|
||||
Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Short('t').String(),
|
||||
}
|
||||
|
||||
kingpin.Parse()
|
||||
@@ -98,8 +96,6 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||
return errAdminPassExcludeAdminPassFile
|
||||
}
|
||||
|
||||
displayDeprecationWarnings(*flags.Templates, *flags.Logo, *flags.Labels)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -143,15 +139,3 @@ func validateSyncInterval(syncInterval string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func displayDeprecationWarnings(templates, logo string, labels []portainer.Pair) {
|
||||
if templates != "" {
|
||||
log.Println("Warning: the --templates / -t flag is deprecated and will be removed in future versions.")
|
||||
}
|
||||
if logo != "" {
|
||||
log.Println("Warning: the --logo flag is deprecated and will be removed in future versions.")
|
||||
}
|
||||
if labels != nil {
|
||||
log.Println("Warning: the --hide-label / -l flag is deprecated and will be removed in future versions.")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,10 +6,10 @@ const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultCheckHealth = "false"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLSVerify = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
|
||||
@@ -4,10 +4,10 @@ const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultCheckHealth = "false"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLSVerify = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
|
||||
+69
-20
@@ -10,11 +10,11 @@ import (
|
||||
"github.com/portainer/portainer/filesystem"
|
||||
"github.com/portainer/portainer/git"
|
||||
"github.com/portainer/portainer/http"
|
||||
"github.com/portainer/portainer/http/client"
|
||||
"github.com/portainer/portainer/jwt"
|
||||
"github.com/portainer/portainer/ldap"
|
||||
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func initCLI() *portainer.CLIFlags {
|
||||
@@ -50,6 +50,11 @@ func initStore(dataStorePath string) *bolt.Store {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.Init()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.MigrateData()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -57,8 +62,8 @@ func initStore(dataStorePath string) *bolt.Store {
|
||||
return store
|
||||
}
|
||||
|
||||
func initStackManager(assetsPath string) portainer.StackManager {
|
||||
return exec.NewStackManager(assetsPath)
|
||||
func initStackManager(assetsPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) {
|
||||
return exec.NewStackManager(assetsPath, signatureService, fileService)
|
||||
}
|
||||
|
||||
func initJWTService(authenticationEnabled bool) portainer.JWTService {
|
||||
@@ -72,6 +77,10 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService {
|
||||
return nil
|
||||
}
|
||||
|
||||
func initDigitalSignatureService() portainer.DigitalSignatureService {
|
||||
return &crypto.ECDSAService{}
|
||||
}
|
||||
|
||||
func initCryptoService() portainer.CryptoService {
|
||||
return &crypto.Service{}
|
||||
}
|
||||
@@ -169,40 +178,66 @@ func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService
|
||||
return &endpoints[0]
|
||||
}
|
||||
|
||||
func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
|
||||
private, public, err := fileService.LoadKeyPair()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return signatureService.ParseKeyPair(private, public)
|
||||
}
|
||||
|
||||
func generateAndStoreKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
|
||||
private, public, err := signatureService.GenerateKeyPair()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
privateHeader, publicHeader := signatureService.PEMHeaders()
|
||||
return fileService.StoreKeyPair(private, public, privateHeader, publicHeader)
|
||||
}
|
||||
|
||||
func initKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
|
||||
existingKeyPair, err := fileService.KeyPairFilesExist()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if existingKeyPair {
|
||||
return loadAndParseKeyPair(fileService, signatureService)
|
||||
}
|
||||
return generateAndStoreKeyPair(fileService, signatureService)
|
||||
}
|
||||
|
||||
func main() {
|
||||
flags := initCLI()
|
||||
|
||||
if *flags.CheckHealth {
|
||||
statuscode, err := http.HealthCheck(*flags.Addr)
|
||||
if err == nil {
|
||||
if statuscode == 200 {
|
||||
log.Println(*flags.Addr, ": Online - response:", statuscode)
|
||||
os.Exit(0)
|
||||
} else {
|
||||
log.Fatal(*flags.Addr, ": Error - response:", statuscode)
|
||||
}
|
||||
}
|
||||
log.Fatal("Connection error:", err.Error())
|
||||
}
|
||||
|
||||
fileService := initFileService(*flags.Data)
|
||||
|
||||
store := initStore(*flags.Data)
|
||||
defer store.Close()
|
||||
|
||||
stackManager := initStackManager(*flags.Assets)
|
||||
|
||||
jwtService := initJWTService(!*flags.NoAuth)
|
||||
|
||||
cryptoService := initCryptoService()
|
||||
|
||||
digitalSignatureService := initDigitalSignatureService()
|
||||
|
||||
ldapService := initLDAPService()
|
||||
|
||||
gitService := initGitService()
|
||||
|
||||
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
|
||||
|
||||
err := initSettings(store.SettingsService, flags)
|
||||
err := initKeyPair(fileService, digitalSignatureService)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
stackManager, err := initStackManager(*flags.Assets, digitalSignatureService, fileService)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = initSettings(store.SettingsService, flags)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -223,16 +258,28 @@ func main() {
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: "primary",
|
||||
URL: *flags.Endpoint,
|
||||
Type: portainer.DockerEnvironment,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: *flags.TLSVerify,
|
||||
TLSSkipVerify: false,
|
||||
TLSSkipVerify: *flags.TLSSkipVerify,
|
||||
TLSCACertPath: *flags.TLSCacert,
|
||||
TLSCertPath: *flags.TLSCert,
|
||||
TLSKeyPath: *flags.TLSKey,
|
||||
},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
}
|
||||
|
||||
agentOnDockerEnvironment, err := client.ExecutePingOperationFromEndpoint(endpoint)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if agentOnDockerEnvironment {
|
||||
endpoint.Type = portainer.AgentOnDockerEnvironment
|
||||
}
|
||||
|
||||
err = store.EndpointService.CreateEndpoint(endpoint)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -288,6 +335,7 @@ func main() {
|
||||
TeamService: store.TeamService,
|
||||
TeamMembershipService: store.TeamMembershipService,
|
||||
EndpointService: store.EndpointService,
|
||||
EndpointGroupService: store.EndpointGroupService,
|
||||
ResourceControlService: store.ResourceControlService,
|
||||
SettingsService: store.SettingsService,
|
||||
RegistryService: store.RegistryService,
|
||||
@@ -299,6 +347,7 @@ func main() {
|
||||
FileService: fileService,
|
||||
LDAPService: ldapService,
|
||||
GitService: gitService,
|
||||
SignatureService: digitalSignatureService,
|
||||
SSL: *flags.SSL,
|
||||
SSLCert: *flags.SSLCert,
|
||||
SSLKey: *flags.SSLKey,
|
||||
|
||||
@@ -142,8 +142,6 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
|
||||
if endpoint != nil {
|
||||
job.logger.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL)
|
||||
endpointsToUpdate = append(endpointsToUpdate, endpoint)
|
||||
} else {
|
||||
job.logger.Printf("No change detected for a stored endpoint. [name: %v] [url: %v]\n", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
|
||||
}
|
||||
} else {
|
||||
job.logger.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+23
-2
@@ -8,11 +8,32 @@ import (
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
func CreateTLSConfig(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) {
|
||||
config := &tls.Config{}
|
||||
config.InsecureSkipVerify = skipServerVerification
|
||||
|
||||
if !skipClientVerification {
|
||||
certificate, err := tls.X509KeyPair(cert, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.Certificates = []tls.Certificate{certificate}
|
||||
}
|
||||
|
||||
if !skipServerVerification {
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
config.RootCAs = caCertPool
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) {
|
||||
TLSConfig := &tls.Config{}
|
||||
|
||||
if config.TLSCertPath != "" && config.TLSKeyPath != "" {
|
||||
if config.TLS && config.TLSCertPath != "" && config.TLSKeyPath != "" {
|
||||
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -21,7 +42,7 @@ func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, er
|
||||
TLSConfig.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
if !config.TLSSkipVerify {
|
||||
if config.TLS && !config.TLSSkipVerify {
|
||||
caCert, err := ioutil.ReadFile(config.TLSCACertPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+13
-1
@@ -28,7 +28,7 @@ const (
|
||||
// TeamMembership errors.
|
||||
const (
|
||||
ErrTeamMembershipNotFound = Error("Team membership not found")
|
||||
ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team.")
|
||||
ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team")
|
||||
)
|
||||
|
||||
// ResourceControl errors.
|
||||
@@ -44,6 +44,12 @@ const (
|
||||
ErrEndpointAccessDenied = Error("Access denied to endpoint")
|
||||
)
|
||||
|
||||
// Endpoint group errors.
|
||||
const (
|
||||
ErrEndpointGroupNotFound = Error("Endpoint group not found")
|
||||
ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group")
|
||||
)
|
||||
|
||||
// Registry errors.
|
||||
const (
|
||||
ErrRegistryNotFound = Error("Registry not found")
|
||||
@@ -57,6 +63,12 @@ const (
|
||||
ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository")
|
||||
)
|
||||
|
||||
// Endpoint extensions error
|
||||
const (
|
||||
ErrEndpointExtensionNotSupported = Error("This extension is not supported")
|
||||
ErrEndpointExtensionAlreadyAssociated = Error("This extension is already associated to the endpoint")
|
||||
)
|
||||
|
||||
// Version errors.
|
||||
const (
|
||||
ErrDBVersionNotFound = Error("DB version not found")
|
||||
|
||||
+56
-20
@@ -12,45 +12,57 @@ import (
|
||||
|
||||
// StackManager represents a service for managing stacks.
|
||||
type StackManager struct {
|
||||
binaryPath string
|
||||
binaryPath string
|
||||
signatureService portainer.DigitalSignatureService
|
||||
fileService portainer.FileService
|
||||
}
|
||||
|
||||
type dockerCLIConfiguration struct {
|
||||
HTTPHeaders struct {
|
||||
ManagerOperationHeader string `json:"X-PortainerAgent-ManagerOperation"`
|
||||
SignatureHeader string `json:"X-PortainerAgent-Signature"`
|
||||
PublicKey string `json:"X-PortainerAgent-PublicKey"`
|
||||
} `json:"HttpHeaders"`
|
||||
}
|
||||
|
||||
// NewStackManager initializes a new StackManager service.
|
||||
func NewStackManager(binaryPath string) *StackManager {
|
||||
return &StackManager{
|
||||
binaryPath: binaryPath,
|
||||
// It also updates the configuration of the Docker CLI binary.
|
||||
func NewStackManager(binaryPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) {
|
||||
manager := &StackManager{
|
||||
binaryPath: binaryPath,
|
||||
signatureService: signatureService,
|
||||
fileService: fileService,
|
||||
}
|
||||
|
||||
err := manager.updateDockerCLIConfiguration(binaryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// Login executes the docker login command against a list of registries (including DockerHub).
|
||||
func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) error {
|
||||
func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) {
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
for _, registry := range registries {
|
||||
if registry.Authentication {
|
||||
registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL)
|
||||
err := runCommandAndCaptureStdErr(command, registryArgs, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
|
||||
}
|
||||
}
|
||||
|
||||
if dockerhub.Authentication {
|
||||
dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password)
|
||||
err := runCommandAndCaptureStdErr(command, dockerhubArgs, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runCommandAndCaptureStdErr(command, dockerhubArgs, nil, "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout executes the docker logout command.
|
||||
func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error {
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
args = append(args, "logout")
|
||||
return runCommandAndCaptureStdErr(command, args, nil)
|
||||
return runCommandAndCaptureStdErr(command, args, nil, "")
|
||||
}
|
||||
|
||||
// Deploy executes the docker stack deploy command.
|
||||
@@ -69,20 +81,22 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint
|
||||
env = append(env, envvar.Name+"="+envvar.Value)
|
||||
}
|
||||
|
||||
return runCommandAndCaptureStdErr(command, args, env)
|
||||
stackFolder := path.Dir(stackFilePath)
|
||||
return runCommandAndCaptureStdErr(command, args, env, stackFolder)
|
||||
}
|
||||
|
||||
// Remove executes the docker stack rm command.
|
||||
func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
args = append(args, "stack", "rm", stack.Name)
|
||||
return runCommandAndCaptureStdErr(command, args, nil)
|
||||
return runCommandAndCaptureStdErr(command, args, nil, "")
|
||||
}
|
||||
|
||||
func runCommandAndCaptureStdErr(command string, args []string, env []string) error {
|
||||
func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error {
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Dir = workingDir
|
||||
|
||||
if env != nil {
|
||||
cmd.Env = os.Environ()
|
||||
@@ -106,9 +120,12 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
|
||||
}
|
||||
|
||||
args := make([]string, 0)
|
||||
args = append(args, "--config", binaryPath)
|
||||
args = append(args, "-H", endpoint.URL)
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
if !endpoint.TLSConfig.TLS && endpoint.TLSConfig.TLSSkipVerify {
|
||||
args = append(args, "--tls")
|
||||
} else if endpoint.TLSConfig.TLS {
|
||||
args = append(args, "--tls")
|
||||
|
||||
if !endpoint.TLSConfig.TLSSkipVerify {
|
||||
@@ -122,3 +139,22 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
|
||||
|
||||
return command, args
|
||||
}
|
||||
|
||||
func (manager *StackManager) updateDockerCLIConfiguration(binaryPath string) error {
|
||||
config := dockerCLIConfiguration{}
|
||||
config.HTTPHeaders.ManagerOperationHeader = "1"
|
||||
|
||||
signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.HTTPHeaders.SignatureHeader = signature
|
||||
config.HTTPHeaders.PublicKey = manager.signatureService.EncodedPublicKey()
|
||||
|
||||
err = manager.fileService.WriteJSONToFile(path.Join(binaryPath, "config.json"), config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+124
-32
@@ -2,6 +2,8 @@ package filesystem
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
@@ -26,6 +28,10 @@ const (
|
||||
ComposeStorePath = "compose"
|
||||
// ComposeFileDefaultName represents the default name of a compose file.
|
||||
ComposeFileDefaultName = "docker-compose.yml"
|
||||
// PrivateKeyFile represents the name on disk of the file containing the private key.
|
||||
PrivateKeyFile = "portainer.key"
|
||||
// PublicKeyFile represents the name on disk of the file containing the public key.
|
||||
PublicKeyFile = "portainer.pub"
|
||||
)
|
||||
|
||||
// Service represents a service for managing files and directories.
|
||||
@@ -42,20 +48,17 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
|
||||
fileStorePath: path.Join(dataStorePath, fileStorePath),
|
||||
}
|
||||
|
||||
// Checking if a mount directory exists is broken with Go on Windows.
|
||||
// This will need to be reviewed after the issue has been fixed in Go.
|
||||
// See: https://github.com/portainer/portainer/issues/474
|
||||
// err := createDirectoryIfNotExist(dataStorePath, 0755)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
err := service.createDirectoryInStoreIfNotExist(TLSStorePath)
|
||||
err := os.MkdirAll(dataStorePath, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = service.createDirectoryInStoreIfNotExist(ComposeStorePath)
|
||||
err = service.createDirectoryInStore(TLSStorePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = service.createDirectoryInStore(ComposeStorePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -76,14 +79,14 @@ func (service *Service) GetStackProjectPath(stackIdentifier string) string {
|
||||
|
||||
// StoreStackFileFromString creates a subfolder in the ComposeStorePath and stores a new file using the content from a string.
|
||||
// It returns the path to the folder where the file is stored.
|
||||
func (service *Service) StoreStackFileFromString(stackIdentifier, stackFileContent string) (string, error) {
|
||||
func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) {
|
||||
stackStorePath := path.Join(ComposeStorePath, stackIdentifier)
|
||||
err := service.createDirectoryInStoreIfNotExist(stackStorePath)
|
||||
err := service.createDirectoryInStore(stackStorePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName)
|
||||
composeFilePath := path.Join(stackStorePath, fileName)
|
||||
data := []byte(stackFileContent)
|
||||
r := bytes.NewReader(data)
|
||||
|
||||
@@ -97,14 +100,14 @@ func (service *Service) StoreStackFileFromString(stackIdentifier, stackFileConte
|
||||
|
||||
// StoreStackFileFromReader creates a subfolder in the ComposeStorePath and stores a new file using the content from an io.Reader.
|
||||
// It returns the path to the folder where the file is stored.
|
||||
func (service *Service) StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error) {
|
||||
func (service *Service) StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error) {
|
||||
stackStorePath := path.Join(ComposeStorePath, stackIdentifier)
|
||||
err := service.createDirectoryInStoreIfNotExist(stackStorePath)
|
||||
err := service.createDirectoryInStore(stackStorePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName)
|
||||
composeFilePath := path.Join(stackStorePath, fileName)
|
||||
|
||||
err = service.createFileInStore(composeFilePath, r)
|
||||
if err != nil {
|
||||
@@ -117,7 +120,7 @@ func (service *Service) StoreStackFileFromReader(stackIdentifier string, r io.Re
|
||||
// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r.
|
||||
func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error {
|
||||
storePath := path.Join(TLSStorePath, folder)
|
||||
err := service.createDirectoryInStoreIfNotExist(storePath)
|
||||
err := service.createDirectoryInStore(storePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -201,26 +204,75 @@ func (service *Service) GetFileContent(filePath string) (string, error) {
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// createDirectoryInStoreIfNotExist creates a new directory in the file store if it doesn't exists on the file system.
|
||||
func (service *Service) createDirectoryInStoreIfNotExist(name string) error {
|
||||
path := path.Join(service.fileStorePath, name)
|
||||
return createDirectoryIfNotExist(path, 0700)
|
||||
}
|
||||
|
||||
// createDirectoryIfNotExist creates a directory if it doesn't exists on the file system.
|
||||
func createDirectoryIfNotExist(path string, mode uint32) error {
|
||||
_, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
err = os.Mkdir(path, os.FileMode(mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
// WriteJSONToFile writes JSON to the specified file.
|
||||
func (service *Service) WriteJSONToFile(path string, content interface{}) error {
|
||||
jsonContent, err := json.Marshal(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ioutil.WriteFile(path, jsonContent, 0644)
|
||||
}
|
||||
|
||||
// KeyPairFilesExist checks for the existence of the key files.
|
||||
func (service *Service) KeyPairFilesExist() (bool, error) {
|
||||
privateKeyPath := path.Join(service.dataStorePath, PrivateKeyFile)
|
||||
exists, err := fileExists(privateKeyPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
publicKeyPath := path.Join(service.dataStorePath, PublicKeyFile)
|
||||
exists, err = fileExists(publicKeyPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// StoreKeyPair store the specified keys content as PEM files on disk.
|
||||
func (service *Service) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error {
|
||||
err := service.createPEMFileInStore(private, privatePEMHeader, PrivateKeyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = service.createPEMFileInStore(public, publicPEMHeader, PublicKeyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadKeyPair retrieve the content of both key files on disk.
|
||||
func (service *Service) LoadKeyPair() ([]byte, []byte, error) {
|
||||
privateKey, err := service.getContentFromPEMFile(PrivateKeyFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
publicKey, err := service.getContentFromPEMFile(PublicKeyFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return privateKey, publicKey, nil
|
||||
}
|
||||
|
||||
// createDirectoryInStore creates a new directory in the file store
|
||||
func (service *Service) createDirectoryInStore(name string) error {
|
||||
path := path.Join(service.fileStorePath, name)
|
||||
return os.MkdirAll(path, 0700)
|
||||
}
|
||||
|
||||
// createFile creates a new file in the file store with the content from r.
|
||||
func (service *Service) createFileInStore(filePath string, r io.Reader) error {
|
||||
path := path.Join(service.fileStorePath, filePath)
|
||||
@@ -238,3 +290,43 @@ func (service *Service) createFileInStore(filePath string, r io.Reader) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) createPEMFileInStore(content []byte, fileType, filePath string) error {
|
||||
path := path.Join(service.fileStorePath, filePath)
|
||||
block := &pem.Block{Type: fileType, Bytes: content}
|
||||
|
||||
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
err = pem.Encode(out, block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) {
|
||||
path := path.Join(service.fileStorePath, filePath)
|
||||
|
||||
fileContent, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(fileContent)
|
||||
return block.Bytes, nil
|
||||
}
|
||||
|
||||
func fileExists(filePath string) (bool, error) {
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
+19
-5
@@ -1,6 +1,9 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/src-d/go-git.v4"
|
||||
)
|
||||
|
||||
@@ -14,12 +17,23 @@ func NewService(dataStorePath string) (*Service, error) {
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// CloneRepository clones a git repository using the specified URL in the specified
|
||||
// ClonePublicRepository clones a public git repository using the specified URL in the specified
|
||||
// destination folder.
|
||||
func (service *Service) CloneRepository(url, destination string) error {
|
||||
_, err := git.PlainClone(destination, false, &git.CloneOptions{
|
||||
URL: url,
|
||||
})
|
||||
func (service *Service) ClonePublicRepository(repositoryURL, destination string) error {
|
||||
return cloneRepository(repositoryURL, destination)
|
||||
}
|
||||
|
||||
// ClonePrivateRepositoryWithBasicAuth clones a private git repository using the specified URL in the specified
|
||||
// destination folder. It will use the specified username and password for basic HTTP authentication.
|
||||
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, destination, username, password string) error {
|
||||
credentials := username + ":" + url.PathEscape(password)
|
||||
repositoryURL = strings.Replace(repositoryURL, "://", "://"+credentials+"@", 1)
|
||||
return cloneRepository(repositoryURL, destination)
|
||||
}
|
||||
|
||||
func cloneRepository(repositoryURL, destination string) error {
|
||||
_, err := git.PlainClone(destination, false, &git.CloneOptions{
|
||||
URL: repositoryURL,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()})
|
||||
}
|
||||
|
||||
@@ -37,14 +37,14 @@ const (
|
||||
)
|
||||
|
||||
// NewAuthHandler returns a new instance of AuthHandler.
|
||||
func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHandler {
|
||||
func NewAuthHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *AuthHandler {
|
||||
h := &AuthHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
authDisabled: authDisabled,
|
||||
}
|
||||
h.Handle("/auth",
|
||||
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))).Methods(http.MethodPost)
|
||||
rateLimiter.LimitAccess(bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth)))).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
+19
-21
@@ -20,6 +20,7 @@ type DockerHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
EndpointService portainer.EndpointService
|
||||
EndpointGroupService portainer.EndpointGroupService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
ProxyManager *proxy.Manager
|
||||
}
|
||||
@@ -35,24 +36,6 @@ func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
func (handler *DockerHandler) checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool {
|
||||
for _, authorizedUserID := range endpoint.AuthorizedUsers {
|
||||
if authorizedUserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
memberships, _ := handler.TeamMembershipService.TeamMembershipsByUserID(userID)
|
||||
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
|
||||
for _, membership := range memberships {
|
||||
if membership.TeamID == authorizedTeamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
@@ -75,17 +58,32 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
if tokenData.Role != portainer.AdministratorRole && !handler.checkEndpointAccessControl(endpoint, tokenData.ID) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
|
||||
memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var proxy http.Handler
|
||||
proxy = handler.ProxyManager.GetProxy(string(endpointID))
|
||||
if proxy == nil {
|
||||
proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
|
||||
dockerhub.Password = ""
|
||||
|
||||
encodeJSON(w, dockerhub, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
+210
-58
@@ -1,7 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/crypto"
|
||||
"github.com/portainer/portainer/http/client"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/proxy"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
@@ -22,6 +27,7 @@ type EndpointHandler struct {
|
||||
Logger *log.Logger
|
||||
authorizeEndpointManagement bool
|
||||
EndpointService portainer.EndpointService
|
||||
EndpointGroupService portainer.EndpointGroupService
|
||||
FileService portainer.FileService
|
||||
ProxyManager *proxy.Manager
|
||||
}
|
||||
@@ -56,19 +62,6 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag
|
||||
}
|
||||
|
||||
type (
|
||||
postEndpointsRequest struct {
|
||||
Name string `valid:"required"`
|
||||
URL string `valid:"required"`
|
||||
PublicURL string `valid:"-"`
|
||||
TLS bool
|
||||
TLSSkipVerify bool
|
||||
TLSSkipClientVerify bool
|
||||
}
|
||||
|
||||
postEndpointsResponse struct {
|
||||
ID int `json:"Id"`
|
||||
}
|
||||
|
||||
putEndpointAccessRequest struct {
|
||||
AuthorizedUsers []int `valid:"-"`
|
||||
AuthorizedTeams []int `valid:"-"`
|
||||
@@ -78,10 +71,24 @@ type (
|
||||
Name string `valid:"-"`
|
||||
URL string `valid:"-"`
|
||||
PublicURL string `valid:"-"`
|
||||
GroupID int `valid:"-"`
|
||||
TLS bool `valid:"-"`
|
||||
TLSSkipVerify bool `valid:"-"`
|
||||
TLSSkipClientVerify bool `valid:"-"`
|
||||
}
|
||||
|
||||
postEndpointPayload struct {
|
||||
name string
|
||||
url string
|
||||
publicURL string
|
||||
groupID int
|
||||
useTLS bool
|
||||
skipTLSServerVerification bool
|
||||
skipTLSClientVerification bool
|
||||
caCert []byte
|
||||
cert []byte
|
||||
key []byte
|
||||
}
|
||||
)
|
||||
|
||||
// handleGetEndpoints handles GET requests on /endpoints
|
||||
@@ -98,7 +105,13 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
|
||||
return
|
||||
}
|
||||
|
||||
filteredEndpoints, err := security.FilterEndpoints(endpoints, securityContext)
|
||||
groups, err := handler.EndpointGroupService.EndpointGroups()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
filteredEndpoints, err := security.FilterEndpoints(endpoints, groups, securityContext)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
@@ -107,6 +120,180 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
|
||||
encodeJSON(w, filteredEndpoints, handler.Logger)
|
||||
}
|
||||
|
||||
func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
|
||||
tlsConfig, err := crypto.CreateTLSConfig(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpointType := portainer.DockerEnvironment
|
||||
if agentOnDockerEnvironment {
|
||||
endpointType = portainer.AgentOnDockerEnvironment
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: payload.name,
|
||||
URL: payload.url,
|
||||
Type: endpointType,
|
||||
GroupID: portainer.EndpointGroupID(payload.groupID),
|
||||
PublicURL: payload.publicURL,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: payload.useTLS,
|
||||
TLSSkipVerify: payload.skipTLSServerVerification,
|
||||
},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
}
|
||||
|
||||
err = handler.EndpointService.CreateEndpoint(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
folder := strconv.Itoa(int(endpoint.ID))
|
||||
|
||||
if !payload.skipTLSServerVerification {
|
||||
r := bytes.NewReader(payload.caCert)
|
||||
// TODO: review the API exposed by the FileService to store
|
||||
// a file from a byte slice and return the path to the stored file instead
|
||||
// of using multiple legacy calls (StoreTLSFile, GetPathForTLSFile) here.
|
||||
err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCA, r)
|
||||
if err != nil {
|
||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
||||
return nil, err
|
||||
}
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
|
||||
endpoint.TLSConfig.TLSCACertPath = caCertPath
|
||||
}
|
||||
|
||||
if !payload.skipTLSClientVerification {
|
||||
r := bytes.NewReader(payload.cert)
|
||||
err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCert, r)
|
||||
if err != nil {
|
||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
||||
return nil, err
|
||||
}
|
||||
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
|
||||
endpoint.TLSConfig.TLSCertPath = certPath
|
||||
|
||||
r = bytes.NewReader(payload.key)
|
||||
err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileKey, r)
|
||||
if err != nil {
|
||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
||||
return nil, err
|
||||
}
|
||||
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
|
||||
endpoint.TLSConfig.TLSKeyPath = keyPath
|
||||
}
|
||||
|
||||
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
|
||||
endpointType := portainer.DockerEnvironment
|
||||
|
||||
if !strings.HasPrefix(payload.url, "unix://") {
|
||||
agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if agentOnDockerEnvironment {
|
||||
endpointType = portainer.AgentOnDockerEnvironment
|
||||
}
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: payload.name,
|
||||
URL: payload.url,
|
||||
Type: endpointType,
|
||||
GroupID: portainer.EndpointGroupID(payload.groupID),
|
||||
PublicURL: payload.publicURL,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: false,
|
||||
},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
}
|
||||
|
||||
err := handler.EndpointService.CreateEndpoint(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
|
||||
if payload.useTLS {
|
||||
return handler.createTLSSecuredEndpoint(payload)
|
||||
}
|
||||
return handler.createUnsecuredEndpoint(payload)
|
||||
}
|
||||
|
||||
func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload, error) {
|
||||
payload := &postEndpointPayload{}
|
||||
payload.name = r.FormValue("Name")
|
||||
payload.url = r.FormValue("URL")
|
||||
payload.publicURL = r.FormValue("PublicURL")
|
||||
|
||||
if payload.name == "" || payload.url == "" {
|
||||
return nil, ErrInvalidRequestFormat
|
||||
}
|
||||
|
||||
rawGroupID := r.FormValue("GroupID")
|
||||
if rawGroupID == "" {
|
||||
payload.groupID = 1
|
||||
} else {
|
||||
groupID, err := strconv.Atoi(rawGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload.groupID = groupID
|
||||
}
|
||||
|
||||
payload.useTLS = r.FormValue("TLS") == "true"
|
||||
|
||||
if payload.useTLS {
|
||||
payload.skipTLSServerVerification = r.FormValue("TLSSkipVerify") == "true"
|
||||
payload.skipTLSClientVerification = r.FormValue("TLSSkipClientVerify") == "true"
|
||||
|
||||
if !payload.skipTLSServerVerification {
|
||||
caCert, err := getUploadedFileContent(r, "TLSCACertFile")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload.caCert = caCert
|
||||
}
|
||||
|
||||
if !payload.skipTLSClientVerification {
|
||||
cert, err := getUploadedFileContent(r, "TLSCertFile")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload.cert = cert
|
||||
key, err := getUploadedFileContent(r, "TLSKeyFile")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload.key = key
|
||||
}
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// handlePostEndpoints handles POST requests on /endpoints
|
||||
func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) {
|
||||
if !handler.authorizeEndpointManagement {
|
||||
@@ -114,59 +301,19 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
|
||||
var req postEndpointsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := govalidator.ValidateStruct(req)
|
||||
payload, err := convertPostEndpointRequestToPayload(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
PublicURL: req.PublicURL,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: req.TLS,
|
||||
TLSSkipVerify: req.TLSSkipVerify,
|
||||
},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
}
|
||||
|
||||
err = handler.EndpointService.CreateEndpoint(endpoint)
|
||||
endpoint, err := handler.createEndpoint(payload)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.TLS {
|
||||
folder := strconv.Itoa(int(endpoint.ID))
|
||||
|
||||
if !req.TLSSkipVerify {
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
|
||||
endpoint.TLSConfig.TLSCACertPath = caCertPath
|
||||
}
|
||||
|
||||
if !req.TLSSkipClientVerify {
|
||||
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
|
||||
endpoint.TLSConfig.TLSCertPath = certPath
|
||||
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
|
||||
endpoint.TLSConfig.TLSKeyPath = keyPath
|
||||
}
|
||||
|
||||
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger)
|
||||
encodeJSON(w, &endpoint, handler.Logger)
|
||||
}
|
||||
|
||||
// handleGetEndpoint handles GET requests on /endpoints/:id
|
||||
@@ -296,6 +443,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
|
||||
endpoint.PublicURL = req.PublicURL
|
||||
}
|
||||
|
||||
if req.GroupID != 0 {
|
||||
endpoint.GroupID = portainer.EndpointGroupID(req.GroupID)
|
||||
}
|
||||
|
||||
folder := strconv.Itoa(int(endpoint.ID))
|
||||
if req.TLS {
|
||||
endpoint.TLSConfig.TLS = true
|
||||
@@ -372,6 +523,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h
|
||||
}
|
||||
|
||||
handler.ProxyManager.DeleteProxy(string(endpointID))
|
||||
handler.ProxyManager.DeleteExtensionProxies(string(endpointID))
|
||||
|
||||
err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/proxy"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ExtensionHandler represents an HTTP API handler for managing Settings.
|
||||
type ExtensionHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
EndpointService portainer.EndpointService
|
||||
ProxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewExtensionHandler returns a new instance of ExtensionHandler.
|
||||
func NewExtensionHandler(bouncer *security.RequestBouncer) *ExtensionHandler {
|
||||
h := &ExtensionHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/{endpointId}/extensions",
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostExtensions))).Methods(http.MethodPost)
|
||||
h.Handle("/{endpointId}/extensions/{extensionType}",
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleDeleteExtensions))).Methods(http.MethodDelete)
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
postExtensionRequest struct {
|
||||
Type int `valid:"required"`
|
||||
URL string `valid:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
func (handler *ExtensionHandler) handlePostExtensions(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["endpointId"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
endpointID := portainer.EndpointID(id)
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(endpointID)
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req postExtensionRequest
|
||||
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
|
||||
}
|
||||
|
||||
extensionType := portainer.EndpointExtensionType(req.Type)
|
||||
|
||||
var extension *portainer.EndpointExtension
|
||||
|
||||
for _, ext := range endpoint.Extensions {
|
||||
if ext.Type == extensionType {
|
||||
extension = &ext
|
||||
}
|
||||
}
|
||||
|
||||
if extension != nil {
|
||||
extension.URL = req.URL
|
||||
} else {
|
||||
extension = &portainer.EndpointExtension{
|
||||
Type: extensionType,
|
||||
URL: req.URL,
|
||||
}
|
||||
endpoint.Extensions = append(endpoint.Extensions, *extension)
|
||||
}
|
||||
|
||||
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, extension, handler.Logger)
|
||||
}
|
||||
|
||||
func (handler *ExtensionHandler) handleDeleteExtensions(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["endpointId"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
endpointID := portainer.EndpointID(id)
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(endpointID)
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
extType, err := strconv.Atoi(vars["extensionType"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
extensionType := portainer.EndpointExtensionType(extType)
|
||||
|
||||
for idx, ext := range endpoint.Extensions {
|
||||
if ext.Type == extensionType {
|
||||
endpoint.Extensions = append(endpoint.Extensions[:idx], endpoint.Extensions[idx+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/proxy"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// StoridgeHandler represents an HTTP API handler for proxying requests to the Docker API.
|
||||
type StoridgeHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
EndpointService portainer.EndpointService
|
||||
EndpointGroupService portainer.EndpointGroupService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
ProxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewStoridgeHandler returns a new instance of StoridgeHandler.
|
||||
func NewStoridgeHandler(bouncer *security.RequestBouncer) *StoridgeHandler {
|
||||
h := &StoridgeHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.PathPrefix("/{id}/extensions/storidge").Handler(
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToStoridgeAPI)))
|
||||
return h
|
||||
}
|
||||
|
||||
func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
parsedID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpointID := portainer.EndpointID(parsedID)
|
||||
endpoint, err := handler.EndpointService.Endpoint(endpointID)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var storidgeExtension *portainer.EndpointExtension
|
||||
for _, extension := range endpoint.Extensions {
|
||||
if extension.Type == portainer.StoridgeEndpointExtension {
|
||||
storidgeExtension = &extension
|
||||
}
|
||||
}
|
||||
|
||||
if storidgeExtension == nil {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrEndpointExtensionNotSupported, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension)
|
||||
|
||||
var proxy http.Handler
|
||||
proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey)
|
||||
if proxy == nil {
|
||||
proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.StripPrefix("/"+id+"/extensions/storidge", proxy).ServeHTTP(w, r)
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/handler/extensions"
|
||||
)
|
||||
|
||||
// Handler is a collection of all the service handlers.
|
||||
@@ -17,8 +19,11 @@ type Handler struct {
|
||||
TeamHandler *TeamHandler
|
||||
TeamMembershipHandler *TeamMembershipHandler
|
||||
EndpointHandler *EndpointHandler
|
||||
EndpointGroupHandler *EndpointGroupHandler
|
||||
RegistryHandler *RegistryHandler
|
||||
DockerHubHandler *DockerHubHandler
|
||||
ExtensionHandler *ExtensionHandler
|
||||
StoridgeHandler *extensions.StoridgeHandler
|
||||
ResourceHandler *ResourceHandler
|
||||
StackHandler *StackHandler
|
||||
StatusHandler *StatusHandler
|
||||
@@ -47,12 +52,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
|
||||
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
|
||||
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
|
||||
if strings.Contains(r.URL.Path, "/docker/") {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/docker/"):
|
||||
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r)
|
||||
} else if strings.Contains(r.URL.Path, "/stacks") {
|
||||
case strings.Contains(r.URL.Path, "/stacks"):
|
||||
http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r)
|
||||
} else {
|
||||
case strings.Contains(r.URL.Path, "/extensions/storidge"):
|
||||
http.StripPrefix("/api/endpoints", h.StoridgeHandler).ServeHTTP(w, r)
|
||||
case strings.Contains(r.URL.Path, "/extensions"):
|
||||
http.StripPrefix("/api/endpoints", h.ExtensionHandler).ServeHTTP(w, r)
|
||||
default:
|
||||
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
|
||||
}
|
||||
case strings.HasPrefix(r.URL.Path, "/api/registries"):
|
||||
@@ -82,7 +94,24 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails.
|
||||
func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// getUploadedFileContent retrieve the content of a file uploaded in the request.
|
||||
// Uses requestParameter as the key to retrieve the file in the request payload.
|
||||
func getUploadedFileContent(request *http.Request, requestParameter string) ([]byte, error) {
|
||||
file, _, err := request.FormFile(requestParameter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileContent, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fileContent, nil
|
||||
}
|
||||
|
||||
@@ -91,6 +91,10 @@ func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
|
||||
for i := range filteredRegistries {
|
||||
filteredRegistries[i].Password = ""
|
||||
}
|
||||
|
||||
encodeJSON(w, filteredRegistries, handler.Logger)
|
||||
}
|
||||
|
||||
@@ -159,6 +163,8 @@ func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
registry.Password = ""
|
||||
|
||||
encodeJSON(w, registry, handler.Logger)
|
||||
}
|
||||
|
||||
|
||||
+25
-26
@@ -70,12 +70,15 @@ func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler {
|
||||
|
||||
type (
|
||||
postStacksRequest struct {
|
||||
Name string `valid:"required"`
|
||||
SwarmID string `valid:"required"`
|
||||
StackFileContent string `valid:""`
|
||||
GitRepository string `valid:""`
|
||||
PathInRepository string `valid:""`
|
||||
Env []portainer.Pair `valid:""`
|
||||
Name string `valid:"required"`
|
||||
SwarmID string `valid:"required"`
|
||||
StackFileContent string `valid:""`
|
||||
RepositoryURL string `valid:""`
|
||||
RepositoryAuthentication bool `valid:""`
|
||||
RepositoryUsername string `valid:""`
|
||||
RepositoryPassword string `valid:""`
|
||||
ComposeFilePathInRepository string `valid:""`
|
||||
Env []portainer.Pair `valid:""`
|
||||
}
|
||||
postStacksResponse struct {
|
||||
ID string `json:"Id"`
|
||||
@@ -179,7 +182,7 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter,
|
||||
Env: req.Env,
|
||||
}
|
||||
|
||||
projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stackFileContent)
|
||||
projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stack.EntryPoint, stackFileContent)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
@@ -263,24 +266,20 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri
|
||||
}
|
||||
|
||||
stackName := req.Name
|
||||
if stackName == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
swarmID := req.SwarmID
|
||||
if swarmID == "" {
|
||||
|
||||
if stackName == "" || swarmID == "" || req.RepositoryURL == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.GitRepository == "" {
|
||||
if req.RepositoryAuthentication && (req.RepositoryUsername == "" || req.RepositoryPassword == "") {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.PathInRepository == "" {
|
||||
req.PathInRepository = filesystem.ComposeFileDefaultName
|
||||
if req.ComposeFilePathInRepository == "" {
|
||||
req.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
stacks, err := handler.StackService.Stacks()
|
||||
@@ -300,7 +299,7 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri
|
||||
ID: portainer.StackID(stackName + "_" + swarmID),
|
||||
Name: stackName,
|
||||
SwarmID: swarmID,
|
||||
EntryPoint: req.PathInRepository,
|
||||
EntryPoint: req.ComposeFilePathInRepository,
|
||||
Env: req.Env,
|
||||
}
|
||||
|
||||
@@ -314,7 +313,11 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.GitService.CloneRepository(req.GitRepository, projectPath)
|
||||
if req.RepositoryAuthentication {
|
||||
err = handler.GitService.ClonePrivateRepositoryWithBasicAuth(req.RepositoryURL, projectPath, req.RepositoryUsername, req.RepositoryPassword)
|
||||
} else {
|
||||
err = handler.GitService.ClonePublicRepository(req.RepositoryURL, projectPath)
|
||||
}
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
@@ -431,7 +434,7 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r
|
||||
Env: env,
|
||||
}
|
||||
|
||||
projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stackFile)
|
||||
projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stack.EntryPoint, stackFile)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
@@ -631,7 +634,7 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
stack.Env = req.Env
|
||||
|
||||
_, err = handler.FileService.StoreStackFileFromString(string(stack.ID), req.StackFileContent)
|
||||
_, err = handler.FileService.StoreStackFileFromString(string(stack.ID), stack.EntryPoint, req.StackFileContent)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
@@ -772,13 +775,9 @@ func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Re
|
||||
func (handler *StackHandler) deployStack(config *stackDeploymentConfig) error {
|
||||
handler.stackCreationMutex.Lock()
|
||||
|
||||
err := handler.StackManager.Login(config.dockerhub, config.registries, config.endpoint)
|
||||
if err != nil {
|
||||
handler.stackCreationMutex.Unlock()
|
||||
return err
|
||||
}
|
||||
handler.StackManager.Login(config.dockerhub, config.registries, config.endpoint)
|
||||
|
||||
err = handler.StackManager.Deploy(config.stack, config.prune, config.endpoint)
|
||||
err := handler.StackManager.Deploy(config.stack, config.prune, config.endpoint)
|
||||
if err != nil {
|
||||
handler.stackCreationMutex.Unlock()
|
||||
return err
|
||||
|
||||
+215
-128
@@ -1,11 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -16,119 +16,135 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/koding/websocketproxy"
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/crypto"
|
||||
"golang.org/x/net/websocket"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
)
|
||||
|
||||
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
|
||||
type WebSocketHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
EndpointService portainer.EndpointService
|
||||
}
|
||||
type (
|
||||
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
|
||||
WebSocketHandler struct {
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
EndpointService portainer.EndpointService
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
connectionUpgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
webSocketExecRequestParams struct {
|
||||
execID string
|
||||
nodeName string
|
||||
endpoint *portainer.Endpoint
|
||||
}
|
||||
|
||||
execStartOperationPayload struct {
|
||||
Tty bool
|
||||
Detach bool
|
||||
}
|
||||
)
|
||||
|
||||
// NewWebSocketHandler returns a new instance of WebSocketHandler.
|
||||
func NewWebSocketHandler() *WebSocketHandler {
|
||||
h := &WebSocketHandler{
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
Router: mux.NewRouter(),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
connectionUpgrader: websocket.Upgrader{},
|
||||
}
|
||||
h.Handle("/websocket/exec", websocket.Handler(h.webSocketDockerExec))
|
||||
h.HandleFunc("/websocket/exec", h.handleWebsocketExec).Methods(http.MethodGet)
|
||||
return h
|
||||
}
|
||||
|
||||
func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) {
|
||||
qry := ws.Request().URL.Query()
|
||||
execID := qry.Get("id")
|
||||
edpID := qry.Get("endpointId")
|
||||
|
||||
parsedID, err := strconv.Atoi(edpID)
|
||||
if err != nil {
|
||||
log.Printf("Unable to parse endpoint ID: %s", err)
|
||||
// handleWebsocketExec handles GET requests on /websocket/exec?id=<execID>&endpointId=<endpointID>&nodeName=<nodeName>
|
||||
// If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint.
|
||||
// If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and
|
||||
// an ExecStart operation HTTP request will be created and hijacked.
|
||||
func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *http.Request) {
|
||||
paramExecID := r.FormValue("id")
|
||||
paramEndpointID := r.FormValue("endpointId")
|
||||
if paramExecID == "" || paramEndpointID == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpointID := portainer.EndpointID(parsedID)
|
||||
endpoint, err := handler.EndpointService.Endpoint(endpointID)
|
||||
endpointID, err := strconv.Atoi(paramEndpointID)
|
||||
if err != nil {
|
||||
log.Printf("Unable to retrieve endpoint: %s", err)
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpointURL, err := url.Parse(endpoint.URL)
|
||||
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
log.Printf("Unable to parse endpoint URL: %s", err)
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var host string
|
||||
if endpointURL.Scheme == "tcp" {
|
||||
host = endpointURL.Host
|
||||
} else if endpointURL.Scheme == "unix" {
|
||||
host = endpointURL.Path
|
||||
params := &webSocketExecRequestParams{
|
||||
endpoint: endpoint,
|
||||
execID: paramExecID,
|
||||
nodeName: r.FormValue("nodeName"),
|
||||
}
|
||||
|
||||
// TODO: Should not be managed here
|
||||
var tlsConfig *tls.Config
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create TLS configuration: %s", err)
|
||||
return
|
||||
err = handler.handleRequest(w, r, params)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
|
||||
if params.nodeName != "" {
|
||||
return handler.proxyWebsocketRequest(w, r, params)
|
||||
}
|
||||
|
||||
websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer websocketConn.Close()
|
||||
|
||||
return hijackExecStartOperation(websocketConn, params.endpoint, params.execID)
|
||||
}
|
||||
|
||||
func (handler *WebSocketHandler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
|
||||
agentURL, err := url.Parse(params.endpoint.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentURL.Scheme = "ws"
|
||||
proxy := websocketproxy.NewProxy(agentURL)
|
||||
|
||||
if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify {
|
||||
agentURL.Scheme = "wss"
|
||||
proxy.Dialer = &websocket.Dialer{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: params.endpoint.TLSConfig.TLSSkipVerify,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if err := hijack(host, endpointURL.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil {
|
||||
log.Fatalf("error during hijack: %s", err)
|
||||
return
|
||||
signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
proxy.Director = func(incoming *http.Request, out http.Header) {
|
||||
out.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
|
||||
}
|
||||
|
||||
r.Header.Del("Origin")
|
||||
proxy.ServeHTTP(w, r)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type execConfig struct {
|
||||
Tty bool
|
||||
Detach bool
|
||||
}
|
||||
|
||||
// hijack allows to upgrade an HTTP connection to a TCP connection
|
||||
// It redirects IO streams for stdin, stdout and stderr to a websocket
|
||||
func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerminal bool, in io.ReadCloser, stdout, stderr io.Writer, started chan io.Closer, data interface{}) error {
|
||||
execConfig := &execConfig{
|
||||
Tty: true,
|
||||
Detach: false,
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(execConfig)
|
||||
func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error {
|
||||
dial, err := createDial(endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling exec config: %s", err)
|
||||
}
|
||||
|
||||
rdr := bytes.NewReader(buf)
|
||||
|
||||
req, err := http.NewRequest(method, path, rdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error during hijack request: %s", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Docker-Client")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Upgrade", "tcp")
|
||||
req.Host = addr
|
||||
|
||||
var (
|
||||
dial net.Conn
|
||||
dialErr error
|
||||
)
|
||||
|
||||
if tlsConfig == nil {
|
||||
dial, dialErr = net.Dial(scheme, addr)
|
||||
} else {
|
||||
dial, dialErr = tls.Dial(scheme, addr, tlsConfig)
|
||||
}
|
||||
|
||||
if dialErr != nil {
|
||||
return dialErr
|
||||
return err
|
||||
}
|
||||
|
||||
// When we set up a TCP connection for hijack, there could be long periods
|
||||
@@ -140,57 +156,128 @@ func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerm
|
||||
tcpConn.SetKeepAlive(true)
|
||||
tcpConn.SetKeepAlivePeriod(30 * time.Second)
|
||||
}
|
||||
|
||||
httpConn := httputil.NewClientConn(dial, nil)
|
||||
defer httpConn.Close()
|
||||
|
||||
execStartRequest, err := createExecStartRequest(execID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clientconn := httputil.NewClientConn(dial, nil)
|
||||
defer clientconn.Close()
|
||||
|
||||
// Server hijacks the connection, error 'connection closed' expected
|
||||
clientconn.Do(req)
|
||||
|
||||
rwc, br := clientconn.Hijack()
|
||||
defer rwc.Close()
|
||||
|
||||
if started != nil {
|
||||
started <- rwc
|
||||
err = hijackRequest(websocketConn, httpConn, execStartRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var receiveStdout chan error
|
||||
|
||||
if stdout != nil || stderr != nil {
|
||||
go func() (err error) {
|
||||
if setRawTerminal && stdout != nil {
|
||||
_, err = io.Copy(stdout, br)
|
||||
}
|
||||
return err
|
||||
}()
|
||||
}
|
||||
|
||||
go func() error {
|
||||
if in != nil {
|
||||
io.Copy(rwc, in)
|
||||
}
|
||||
|
||||
if conn, ok := rwc.(interface {
|
||||
CloseWrite() error
|
||||
}); ok {
|
||||
if err := conn.CloseWrite(); err != nil {
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
if stdout != nil || stderr != nil {
|
||||
if err := <-receiveStdout; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
fmt.Println(br)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createDial(endpoint *portainer.Endpoint) (net.Conn, error) {
|
||||
url, err := url.Parse(endpoint.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var host string
|
||||
if url.Scheme == "tcp" {
|
||||
host = url.Host
|
||||
} else if url.Scheme == "unix" {
|
||||
host = url.Path
|
||||
}
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tls.Dial(url.Scheme, host, tlsConfig)
|
||||
}
|
||||
|
||||
return net.Dial(url.Scheme, host)
|
||||
}
|
||||
|
||||
func createExecStartRequest(execID string) (*http.Request, error) {
|
||||
execStartOperationPayload := &execStartOperationPayload{
|
||||
Tty: true,
|
||||
Detach: false,
|
||||
}
|
||||
|
||||
encodedBody := bytes.NewBuffer(nil)
|
||||
err := json.NewEncoder(encodedBody).Encode(execStartOperationPayload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("POST", "/exec/"+execID+"/start", encodedBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Connection", "Upgrade")
|
||||
request.Header.Set("Upgrade", "tcp")
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error {
|
||||
// Server hijacks the connection, error 'connection closed' expected
|
||||
resp, err := httpConn.Do(request)
|
||||
if err != httputil.ErrPersistEOF {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("unable to upgrade to tcp, received %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
tcpConn, brw := httpConn.Hijack()
|
||||
defer tcpConn.Close()
|
||||
|
||||
errorChan := make(chan error, 1)
|
||||
go streamFromTCPConnToWebsocketConn(websocketConn, brw, errorChan)
|
||||
go streamFromWebsocketConnToTCPConn(websocketConn, tcpConn, errorChan)
|
||||
|
||||
err = <-errorChan
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) {
|
||||
for {
|
||||
_, in, err := websocketConn.ReadMessage()
|
||||
if err != nil {
|
||||
errorChan <- err
|
||||
break
|
||||
}
|
||||
|
||||
_, err = tcpConn.Write(in)
|
||||
if err != nil {
|
||||
errorChan <- err
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamFromTCPConnToWebsocketConn(websocketConn *websocket.Conn, br *bufio.Reader, errorChan chan error) {
|
||||
for {
|
||||
out := make([]byte, 1024)
|
||||
_, err := br.Read(out)
|
||||
if err != nil {
|
||||
errorChan <- err
|
||||
break
|
||||
}
|
||||
|
||||
err = websocketConn.WriteMessage(websocket.TextMessage, out)
|
||||
if err != nil {
|
||||
errorChan <- err
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HealthCheck GETs /api/status
|
||||
func HealthCheck(addr string) (int, error) {
|
||||
resp, err := http.Get("http://" + addr + "/api/status")
|
||||
return resp.StatusCode, err
|
||||
}
|
||||
@@ -98,9 +98,12 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team
|
||||
}
|
||||
|
||||
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
|
||||
metadata := make(map[string]interface{})
|
||||
metadata["ResourceControl"] = resourceControl
|
||||
object["Portainer"] = metadata
|
||||
if object["Portainer"] == nil {
|
||||
object["Portainer"] = make(map[string]interface{})
|
||||
}
|
||||
|
||||
portainerMetadata := object["Portainer"].(map[string]interface{})
|
||||
portainerMetadata["ResourceControl"] = resourceControl
|
||||
return object
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/archive"
|
||||
)
|
||||
|
||||
type postDockerfileRequest struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
// buildOperation inspects the "Content-Type" header to determine if it needs to alter the request.
|
||||
// If the value of the header is empty, it means that a Dockerfile is posted via upload, the function
|
||||
// will extract the file content from the request body, tar it, and rewrite the body.
|
||||
// If the value of the header contains "application/json", it means that the content of a Dockerfile is posted
|
||||
// in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and
|
||||
// rewrite the body of the request.
|
||||
// In any other case, it will leave the request unaltered.
|
||||
func buildOperation(request *http.Request) error {
|
||||
contentTypeHeader := request.Header.Get("Content-Type")
|
||||
if contentTypeHeader != "" && !strings.Contains(contentTypeHeader, "application/json") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var dockerfileContent []byte
|
||||
|
||||
if contentTypeHeader == "" {
|
||||
body, err := ioutil.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dockerfileContent = body
|
||||
} else {
|
||||
var req postDockerfileRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
dockerfileContent = []byte(req.Content)
|
||||
}
|
||||
|
||||
buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request.Body = ioutil.NopCloser(bytes.NewReader(buffer))
|
||||
request.ContentLength = int64(len(buffer))
|
||||
request.Header.Set("Content-Type", "application/x-tar")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const (
|
||||
|
||||
// configListOperation extracts the response as a JSON object, loop through the configs array
|
||||
// decorate and/or filter the configs based on resource controls before rewriting the response
|
||||
func configListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func configListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
|
||||
// ConfigList response is a JSON array
|
||||
@@ -39,7 +39,7 @@ func configListOperation(request *http.Request, response *http.Response, executo
|
||||
// configInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the config based on resource control (check are done based on the configID and optional Swarm service ID)
|
||||
// and either rewrite an access denied response or a decorated config.
|
||||
func configInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func configInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// ConfigInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
|
||||
@@ -16,7 +16,7 @@ const (
|
||||
|
||||
// containerListOperation extracts the response as a JSON object, loop through the containers array
|
||||
// decorate and/or filter the containers based on resource controls before rewriting the response
|
||||
func containerListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func containerListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
// ContainerList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
@@ -47,7 +47,7 @@ func containerListOperation(request *http.Request, response *http.Response, exec
|
||||
// containerInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID)
|
||||
// and either rewrite an access denied response or a decorated container.
|
||||
func containerInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func containerInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// ContainerInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
|
||||
+28
-12
@@ -15,17 +15,21 @@ type proxyFactory struct {
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
SettingsService portainer.SettingsService
|
||||
RegistryService portainer.RegistryService
|
||||
DockerHubService portainer.DockerHubService
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
}
|
||||
|
||||
func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
|
||||
func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler {
|
||||
u.Scheme = "http"
|
||||
return factory.createReverseProxy(u)
|
||||
return newSingleHostReverseProxyWithHostHeader(u)
|
||||
}
|
||||
|
||||
func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool) (http.Handler, error) {
|
||||
u.Scheme = "https"
|
||||
proxy := factory.createReverseProxy(u)
|
||||
config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
|
||||
|
||||
proxy := factory.createDockerReverseProxy(u, enableSignature)
|
||||
config, err := crypto.CreateTLSConfiguration(tlsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -34,26 +38,42 @@ func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpo
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (factory *proxyFactory) newSocketProxy(path string) http.Handler {
|
||||
func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, enableSignature bool) http.Handler {
|
||||
u.Scheme = "http"
|
||||
return factory.createDockerReverseProxy(u, enableSignature)
|
||||
}
|
||||
|
||||
func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
|
||||
proxy := &socketProxy{}
|
||||
transport := &proxyTransport{
|
||||
enableSignature: false,
|
||||
ResourceControlService: factory.ResourceControlService,
|
||||
TeamMembershipService: factory.TeamMembershipService,
|
||||
SettingsService: factory.SettingsService,
|
||||
RegistryService: factory.RegistryService,
|
||||
DockerHubService: factory.DockerHubService,
|
||||
dockerTransport: newSocketTransport(path),
|
||||
}
|
||||
proxy.Transport = transport
|
||||
return proxy
|
||||
}
|
||||
|
||||
func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReverseProxy {
|
||||
func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignature bool) *httputil.ReverseProxy {
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(u)
|
||||
transport := &proxyTransport{
|
||||
enableSignature: enableSignature,
|
||||
ResourceControlService: factory.ResourceControlService,
|
||||
TeamMembershipService: factory.TeamMembershipService,
|
||||
SettingsService: factory.SettingsService,
|
||||
dockerTransport: newHTTPTransport(),
|
||||
RegistryService: factory.RegistryService,
|
||||
DockerHubService: factory.DockerHubService,
|
||||
dockerTransport: &http.Transport{},
|
||||
}
|
||||
|
||||
if enableSignature {
|
||||
transport.SignatureService = factory.SignatureService
|
||||
}
|
||||
|
||||
proxy.Transport = transport
|
||||
return proxy
|
||||
}
|
||||
@@ -65,7 +85,3 @@ func newSocketTransport(socketPath string) *http.Transport {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newHTTPTransport() *http.Transport {
|
||||
return &http.Transport{}
|
||||
}
|
||||
|
||||
+68
-14
@@ -3,25 +3,43 @@ package proxy
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/orcaman/concurrent-map"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
// Manager represents a service used to manage Docker proxies.
|
||||
type Manager struct {
|
||||
proxyFactory *proxyFactory
|
||||
proxies cmap.ConcurrentMap
|
||||
}
|
||||
type (
|
||||
// Manager represents a service used to manage Docker proxies.
|
||||
Manager struct {
|
||||
proxyFactory *proxyFactory
|
||||
proxies cmap.ConcurrentMap
|
||||
extensionProxies cmap.ConcurrentMap
|
||||
}
|
||||
|
||||
// ManagerParams represents the required parameters to create a new Manager instance.
|
||||
ManagerParams struct {
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
SettingsService portainer.SettingsService
|
||||
RegistryService portainer.RegistryService
|
||||
DockerHubService portainer.DockerHubService
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
}
|
||||
)
|
||||
|
||||
// NewManager initializes a new proxy Service
|
||||
func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService) *Manager {
|
||||
func NewManager(parameters *ManagerParams) *Manager {
|
||||
return &Manager{
|
||||
proxies: cmap.New(),
|
||||
proxies: cmap.New(),
|
||||
extensionProxies: cmap.New(),
|
||||
proxyFactory: &proxyFactory{
|
||||
ResourceControlService: resourceControlService,
|
||||
TeamMembershipService: teamMembershipService,
|
||||
SettingsService: settingsService,
|
||||
ResourceControlService: parameters.ResourceControlService,
|
||||
TeamMembershipService: parameters.TeamMembershipService,
|
||||
SettingsService: parameters.SettingsService,
|
||||
RegistryService: parameters.RegistryService,
|
||||
DockerHubService: parameters.DockerHubService,
|
||||
SignatureService: parameters.SignatureService,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -36,18 +54,23 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enableSignature := false
|
||||
if endpoint.Type == portainer.AgentOnDockerEnvironment {
|
||||
enableSignature = true
|
||||
}
|
||||
|
||||
if endpointURL.Scheme == "tcp" {
|
||||
if endpoint.TLSConfig.TLS {
|
||||
proxy, err = manager.proxyFactory.newHTTPSProxy(endpointURL, endpoint)
|
||||
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
|
||||
proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
proxy = manager.proxyFactory.newHTTPProxy(endpointURL)
|
||||
proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL, enableSignature)
|
||||
}
|
||||
} else {
|
||||
// Assume unix:// scheme
|
||||
proxy = manager.proxyFactory.newSocketProxy(endpointURL.Path)
|
||||
proxy = manager.proxyFactory.newDockerSocketProxy(endpointURL.Path)
|
||||
}
|
||||
|
||||
manager.proxies.Set(string(endpoint.ID), proxy)
|
||||
@@ -67,3 +90,34 @@ func (manager *Manager) GetProxy(key string) http.Handler {
|
||||
func (manager *Manager) DeleteProxy(key string) {
|
||||
manager.proxies.Remove(key)
|
||||
}
|
||||
|
||||
// CreateAndRegisterExtensionProxy creates a new HTTP reverse proxy for an extension and adds it to the registered proxies.
|
||||
func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL string) (http.Handler, error) {
|
||||
|
||||
extensionURL, err := url.Parse(extensionAPIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := manager.proxyFactory.newExtensionHTTPPRoxy(extensionURL)
|
||||
manager.extensionProxies.Set(key, proxy)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// GetExtensionProxy returns the extension proxy associated to a key
|
||||
func (manager *Manager) GetExtensionProxy(key string) http.Handler {
|
||||
proxy, ok := manager.extensionProxies.Get(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return proxy.(http.Handler)
|
||||
}
|
||||
|
||||
// DeleteExtensionProxies deletes all the extension proxies associated to a key
|
||||
func (manager *Manager) DeleteExtensionProxies(key string) {
|
||||
for _, k := range manager.extensionProxies.Keys() {
|
||||
if strings.Contains(k, key+"_") {
|
||||
manager.extensionProxies.Remove(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const (
|
||||
|
||||
// networkListOperation extracts the response as a JSON object, loop through the networks array
|
||||
// decorate and/or filter the networks based on resource controls before rewriting the response
|
||||
func networkListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func networkListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
// NetworkList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
|
||||
@@ -39,7 +39,7 @@ func networkListOperation(request *http.Request, response *http.Response, execut
|
||||
// networkInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the network based on resource control and either rewrite an access denied response
|
||||
// or a decorated network.
|
||||
func networkInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func networkInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// NetworkInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
)
|
||||
|
||||
func createRegistryAuthenticationHeader(serverAddress string, accessContext *registryAccessContext) *registryAuthenticationHeader {
|
||||
var authenticationHeader *registryAuthenticationHeader
|
||||
|
||||
if serverAddress == "" {
|
||||
authenticationHeader = ®istryAuthenticationHeader{
|
||||
Username: accessContext.dockerHub.Username,
|
||||
Password: accessContext.dockerHub.Password,
|
||||
Serveraddress: "docker.io",
|
||||
}
|
||||
} else {
|
||||
var matchingRegistry *portainer.Registry
|
||||
for _, registry := range accessContext.registries {
|
||||
if registry.URL == serverAddress &&
|
||||
(accessContext.isAdmin || (!accessContext.isAdmin && security.AuthorizedRegistryAccess(®istry, accessContext.userID, accessContext.teamMemberships))) {
|
||||
matchingRegistry = ®istry
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchingRegistry != nil {
|
||||
authenticationHeader = ®istryAuthenticationHeader{
|
||||
Username: matchingRegistry.Username,
|
||||
Password: matchingRegistry.Password,
|
||||
Serveraddress: matchingRegistry.URL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return authenticationHeader
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
const (
|
||||
// ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse
|
||||
ErrEmptyResponseBody = portainer.Error("Empty response body")
|
||||
// ErrInvalidResponseContent defines an error raised when Portainer excepts a JSON array and get something else.
|
||||
ErrInvalidResponseContent = portainer.Error("Invalid Docker response")
|
||||
)
|
||||
|
||||
func extractJSONField(jsonObject map[string]interface{}, key string) map[string]interface{} {
|
||||
@@ -39,8 +41,17 @@ func getResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responseObject := responseData.([]interface{})
|
||||
return responseObject, nil
|
||||
switch responseObject := responseData.(type) {
|
||||
case []interface{}:
|
||||
return responseObject, nil
|
||||
case map[string]interface{}:
|
||||
if responseObject["message"] != nil {
|
||||
return nil, portainer.Error(responseObject["message"].(string))
|
||||
}
|
||||
return nil, ErrInvalidResponseContent
|
||||
default:
|
||||
return nil, ErrInvalidResponseContent
|
||||
}
|
||||
}
|
||||
|
||||
func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) {
|
||||
|
||||
@@ -14,7 +14,7 @@ const (
|
||||
|
||||
// secretListOperation extracts the response as a JSON object, loop through the secrets array
|
||||
// decorate and/or filter the secrets based on resource controls before rewriting the response
|
||||
func secretListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func secretListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
|
||||
// SecretList response is a JSON array
|
||||
@@ -39,7 +39,7 @@ func secretListOperation(request *http.Request, response *http.Response, executo
|
||||
// secretInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID)
|
||||
// and either rewrite an access denied response or a decorated secret.
|
||||
func secretInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func secretInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// SecretInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
|
||||
@@ -15,7 +15,7 @@ const (
|
||||
|
||||
// serviceListOperation extracts the response as a JSON array, loop through the service array
|
||||
// decorate and/or filter the services based on resource controls before rewriting the response
|
||||
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func serviceListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
// ServiceList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
@@ -39,7 +39,7 @@ func serviceListOperation(request *http.Request, response *http.Response, execut
|
||||
// serviceInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the service based on resource control and either rewrite an access denied response
|
||||
// or a decorated service.
|
||||
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func serviceInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// ServiceInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
|
||||
@@ -15,7 +15,7 @@ const (
|
||||
|
||||
// taskListOperation extracts the response as a JSON object, loop through the tasks array
|
||||
// and filter the tasks based on resource controls before rewriting the response
|
||||
func taskListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func taskListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
|
||||
// TaskList response is a JSON array
|
||||
|
||||
+140
-5
@@ -1,20 +1,29 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
)
|
||||
|
||||
var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`)
|
||||
|
||||
type (
|
||||
proxyTransport struct {
|
||||
dockerTransport *http.Transport
|
||||
enableSignature bool
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
RegistryService portainer.RegistryService
|
||||
DockerHubService portainer.DockerHubService
|
||||
SettingsService portainer.SettingsService
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
}
|
||||
restrictedOperationContext struct {
|
||||
isAdmin bool
|
||||
@@ -22,11 +31,24 @@ type (
|
||||
userTeamIDs []portainer.TeamID
|
||||
resourceControls []portainer.ResourceControl
|
||||
}
|
||||
registryAccessContext struct {
|
||||
isAdmin bool
|
||||
userID portainer.UserID
|
||||
teamMemberships []portainer.TeamMembership
|
||||
registries []portainer.Registry
|
||||
dockerHub *portainer.DockerHub
|
||||
}
|
||||
registryAuthenticationHeader struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Serveraddress string `json:"serveraddress"`
|
||||
}
|
||||
operationExecutor struct {
|
||||
operationContext *restrictedOperationContext
|
||||
labelBlackList []portainer.Pair
|
||||
}
|
||||
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
|
||||
restrictedOperationRequest func(*http.Response, *operationExecutor) error
|
||||
operationRequest func(*http.Request) error
|
||||
)
|
||||
|
||||
func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
@@ -38,7 +60,18 @@ func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Resp
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) {
|
||||
path := request.URL.Path
|
||||
path := apiVersionRe.ReplaceAllString(request.URL.Path, "")
|
||||
request.URL.Path = path
|
||||
|
||||
if p.enableSignature {
|
||||
signature, err := p.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, p.SignatureService.EncodedPublicKey())
|
||||
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/configs"):
|
||||
@@ -59,6 +92,10 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
|
||||
return p.proxyNodeRequest(request)
|
||||
case strings.HasPrefix(path, "/tasks"):
|
||||
return p.proxyTaskRequest(request)
|
||||
case strings.HasPrefix(path, "/build"):
|
||||
return p.proxyBuildRequest(request)
|
||||
case strings.HasPrefix(path, "/images"):
|
||||
return p.proxyImageRequest(request)
|
||||
default:
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
@@ -116,7 +153,7 @@ func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Res
|
||||
func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/services/create":
|
||||
return p.executeDockerRequest(request)
|
||||
return p.replaceRegistryAuthenticationHeader(request)
|
||||
|
||||
case "/services":
|
||||
return p.rewriteOperation(request, serviceListOperation)
|
||||
@@ -228,6 +265,58 @@ func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyBuildRequest(request *http.Request) (*http.Response, error) {
|
||||
return p.interceptAndRewriteRequest(request, buildOperation)
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyImageRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/images/create":
|
||||
return p.replaceRegistryAuthenticationHeader(request)
|
||||
default:
|
||||
if path.Base(requestPath) == "push" && request.Method == http.MethodPost {
|
||||
return p.replaceRegistryAuthenticationHeader(request)
|
||||
}
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) replaceRegistryAuthenticationHeader(request *http.Request) (*http.Response, error) {
|
||||
accessContext, err := p.createRegistryAccessContext(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
originalHeader := request.Header.Get("X-Registry-Auth")
|
||||
|
||||
if originalHeader != "" {
|
||||
|
||||
decodedHeaderData, err := base64.StdEncoding.DecodeString(originalHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var originalHeaderData registryAuthenticationHeader
|
||||
err = json.Unmarshal(decodedHeaderData, &originalHeaderData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext)
|
||||
|
||||
headerData, err := json.Marshal(authenticationHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
header := base64.StdEncoding.EncodeToString(headerData)
|
||||
|
||||
request.Header.Set("X-Registry-Auth", header)
|
||||
}
|
||||
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
|
||||
// restrictedOperation ensures that the current user has the required authorizations
|
||||
// before executing the original request.
|
||||
func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) {
|
||||
@@ -263,7 +352,7 @@ func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID s
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
|
||||
// rewriteOperation will create a new operation context with data that will be used
|
||||
// rewriteOperationWithLabelFiltering will create a new operation context with data that will be used
|
||||
// to decorate the original request's response as well as retrieve all the black listed labels
|
||||
// to filter the resources.
|
||||
func (p *proxyTransport) rewriteOperationWithLabelFiltering(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) {
|
||||
@@ -300,13 +389,22 @@ func (p *proxyTransport) rewriteOperation(request *http.Request, operation restr
|
||||
return p.executeRequestAndRewriteResponse(request, operation, executor)
|
||||
}
|
||||
|
||||
func (p *proxyTransport) interceptAndRewriteRequest(request *http.Request, operation operationRequest) (*http.Response, error) {
|
||||
err := operation(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
|
||||
func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) {
|
||||
response, err := p.executeDockerRequest(request)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
err = operation(request, response, executor)
|
||||
err = operation(response, executor)
|
||||
return response, err
|
||||
}
|
||||
|
||||
@@ -325,6 +423,43 @@ func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Re
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
|
||||
func (p *proxyTransport) createRegistryAccessContext(request *http.Request) (*registryAccessContext, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessContext := ®istryAccessContext{
|
||||
isAdmin: true,
|
||||
userID: tokenData.ID,
|
||||
}
|
||||
|
||||
hub, err := p.DockerHubService.DockerHub()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accessContext.dockerHub = hub
|
||||
|
||||
registries, err := p.RegistryService.Registries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accessContext.registries = registries
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
accessContext.isAdmin = false
|
||||
|
||||
teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessContext.teamMemberships = teamMemberships
|
||||
}
|
||||
|
||||
return accessContext, nil
|
||||
}
|
||||
|
||||
func (p *proxyTransport) createOperationContext(request *http.Request) (*restrictedOperationContext, error) {
|
||||
var err error
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
|
||||
@@ -15,7 +15,7 @@ const (
|
||||
|
||||
// volumeListOperation extracts the response as a JSON object, loop through the volume array
|
||||
// decorate and/or filter the volumes based on resource controls before rewriting the response
|
||||
func volumeListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func volumeListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
// VolumeList response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
@@ -48,7 +48,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo
|
||||
// volumeInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the volume based on any existing resource control and either rewrite an access denied response
|
||||
// or a decorated volume.
|
||||
func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
func volumeInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// VolumeInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
|
||||
@@ -121,3 +121,44 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AuthorizedEndpointAccess ensure that the user can access the specified endpoint.
|
||||
// It will check if the user is part of the authorized users or part of a team that is
|
||||
// listed in the authorized teams of the endpoint and the associated group.
|
||||
func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
groupAccess := authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
|
||||
if !groupAccess {
|
||||
return authorizedAccess(userID, memberships, endpoint.AuthorizedUsers, endpoint.AuthorizedTeams)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AuthorizedEndpointGroupAccess ensure that the user can access the specified endpoint group.
|
||||
// It will check if the user is part of the authorized users or part of a team that is
|
||||
// listed in the authorized teams.
|
||||
func AuthorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
|
||||
}
|
||||
|
||||
// AuthorizedRegistryAccess ensure that the user can access the specified registry.
|
||||
// It will check if the user is part of the authorized users or part of a team that is
|
||||
// listed in the authorized teams.
|
||||
func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
return authorizedAccess(userID, memberships, registry.AuthorizedUsers, registry.AuthorizedTeams)
|
||||
}
|
||||
|
||||
func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, authorizedUsers []portainer.UserID, authorizedTeams []portainer.TeamID) bool {
|
||||
for _, authorizedUserID := range authorizedUsers {
|
||||
if authorizedUserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, membership := range memberships {
|
||||
for _, authorizedTeamID := range authorizedTeams {
|
||||
if membership.TeamID == authorizedTeamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type (
|
||||
// RequestBouncer represents an entity that manages API request accesses
|
||||
RequestBouncer struct {
|
||||
jwtService portainer.JWTService
|
||||
userService portainer.UserService
|
||||
teamMembershipService portainer.TeamMembershipService
|
||||
authDisabled bool
|
||||
}
|
||||
@@ -27,9 +28,10 @@ type (
|
||||
)
|
||||
|
||||
// NewRequestBouncer initializes a new RequestBouncer
|
||||
func NewRequestBouncer(jwtService portainer.JWTService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer {
|
||||
func NewRequestBouncer(jwtService portainer.JWTService, userService portainer.UserService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer {
|
||||
return &RequestBouncer{
|
||||
jwtService: jwtService,
|
||||
userService: userService,
|
||||
teamMembershipService: teamMembershipService,
|
||||
authDisabled: authDisabled,
|
||||
}
|
||||
@@ -136,6 +138,15 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han
|
||||
httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = bouncer.userService.User(tokenData.ID)
|
||||
if err != nil && err == portainer.ErrUserNotFound {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
tokenData = &portainer.TokenData{
|
||||
Role: portainer.AdministratorRole,
|
||||
|
||||
+24
-27
@@ -69,7 +69,7 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
|
||||
filteredRegistries = make([]portainer.Registry, 0)
|
||||
|
||||
for _, registry := range registries {
|
||||
if isRegistryAccessAuthorized(®istry, context.UserID, context.UserMemberships) {
|
||||
if AuthorizedRegistryAccess(®istry, context.UserID, context.UserMemberships) {
|
||||
filteredRegistries = append(filteredRegistries, registry)
|
||||
}
|
||||
}
|
||||
@@ -79,15 +79,17 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
|
||||
}
|
||||
|
||||
// FilterEndpoints filters endpoints based on user role and team memberships.
|
||||
// Non administrator users only have access to authorized endpoints.
|
||||
func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
|
||||
// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups).
|
||||
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
|
||||
filteredEndpoints := endpoints
|
||||
|
||||
if !context.IsAdmin {
|
||||
filteredEndpoints = make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if isEndpointAccessAuthorized(&endpoint, context.UserID, context.UserMemberships) {
|
||||
endpointGroup := getAssociatedGroup(&endpoint, groups)
|
||||
|
||||
if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
@@ -96,34 +98,29 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC
|
||||
return filteredEndpoints, nil
|
||||
}
|
||||
|
||||
func isRegistryAccessAuthorized(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
for _, authorizedUserID := range registry.AuthorizedUsers {
|
||||
if authorizedUserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, membership := range memberships {
|
||||
for _, authorizedTeamID := range registry.AuthorizedTeams {
|
||||
if membership.TeamID == authorizedTeamID {
|
||||
return true
|
||||
// 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 false
|
||||
|
||||
return filteredEndpointGroups, nil
|
||||
}
|
||||
|
||||
func isEndpointAccessAuthorized(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
for _, authorizedUserID := range endpoint.AuthorizedUsers {
|
||||
if authorizedUserID == userID {
|
||||
return true
|
||||
func getAssociatedGroup(endpoint *portainer.Endpoint, groups []portainer.EndpointGroup) *portainer.EndpointGroup {
|
||||
for _, group := range groups {
|
||||
if group.ID == endpoint.GroupID {
|
||||
return &group
|
||||
}
|
||||
}
|
||||
for _, membership := range memberships {
|
||||
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
|
||||
if membership.TeamID == authorizedTeamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+34
-3
@@ -1,8 +1,11 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/handler"
|
||||
"github.com/portainer/portainer/http/handler/extensions"
|
||||
"github.com/portainer/portainer/http/proxy"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
@@ -21,6 +24,7 @@ type Server struct {
|
||||
TeamService portainer.TeamService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
EndpointService portainer.EndpointService
|
||||
EndpointGroupService portainer.EndpointGroupService
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
SettingsService portainer.SettingsService
|
||||
CryptoService portainer.CryptoService
|
||||
@@ -32,6 +36,7 @@ type Server struct {
|
||||
StackManager portainer.StackManager
|
||||
LDAPService portainer.LDAPService
|
||||
GitService portainer.GitService
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
Handler *handler.Handler
|
||||
SSL bool
|
||||
SSLCert string
|
||||
@@ -40,11 +45,20 @@ type Server struct {
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (server *Server) Start() error {
|
||||
requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled)
|
||||
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService)
|
||||
requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled)
|
||||
proxyManagerParameters := &proxy.ManagerParams{
|
||||
ResourceControlService: server.ResourceControlService,
|
||||
TeamMembershipService: server.TeamMembershipService,
|
||||
SettingsService: server.SettingsService,
|
||||
RegistryService: server.RegistryService,
|
||||
DockerHubService: server.DockerHubService,
|
||||
SignatureService: server.SignatureService,
|
||||
}
|
||||
proxyManager := proxy.NewManager(proxyManagerParameters)
|
||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||
|
||||
var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public"))
|
||||
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
|
||||
var authHandler = handler.NewAuthHandler(requestBouncer, rateLimiter, server.AuthDisabled)
|
||||
authHandler.UserService = server.UserService
|
||||
authHandler.CryptoService = server.CryptoService
|
||||
authHandler.JWTService = server.JWTService
|
||||
@@ -71,14 +85,20 @@ func (server *Server) Start() error {
|
||||
templatesHandler.SettingsService = server.SettingsService
|
||||
var dockerHandler = handler.NewDockerHandler(requestBouncer)
|
||||
dockerHandler.EndpointService = server.EndpointService
|
||||
dockerHandler.EndpointGroupService = server.EndpointGroupService
|
||||
dockerHandler.TeamMembershipService = server.TeamMembershipService
|
||||
dockerHandler.ProxyManager = proxyManager
|
||||
var websocketHandler = handler.NewWebSocketHandler()
|
||||
websocketHandler.EndpointService = server.EndpointService
|
||||
websocketHandler.SignatureService = server.SignatureService
|
||||
var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement)
|
||||
endpointHandler.EndpointService = server.EndpointService
|
||||
endpointHandler.EndpointGroupService = server.EndpointGroupService
|
||||
endpointHandler.FileService = server.FileService
|
||||
endpointHandler.ProxyManager = proxyManager
|
||||
var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer)
|
||||
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
|
||||
endpointGroupHandler.EndpointService = server.EndpointService
|
||||
var registryHandler = handler.NewRegistryHandler(requestBouncer)
|
||||
registryHandler.RegistryService = server.RegistryService
|
||||
var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer)
|
||||
@@ -96,6 +116,14 @@ func (server *Server) Start() error {
|
||||
stackHandler.GitService = server.GitService
|
||||
stackHandler.RegistryService = server.RegistryService
|
||||
stackHandler.DockerHubService = server.DockerHubService
|
||||
var extensionHandler = handler.NewExtensionHandler(requestBouncer)
|
||||
extensionHandler.EndpointService = server.EndpointService
|
||||
extensionHandler.ProxyManager = proxyManager
|
||||
var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer)
|
||||
storidgeHandler.EndpointService = server.EndpointService
|
||||
storidgeHandler.EndpointGroupService = server.EndpointGroupService
|
||||
storidgeHandler.TeamMembershipService = server.TeamMembershipService
|
||||
storidgeHandler.ProxyManager = proxyManager
|
||||
|
||||
server.Handler = &handler.Handler{
|
||||
AuthHandler: authHandler,
|
||||
@@ -103,6 +131,7 @@ func (server *Server) Start() error {
|
||||
TeamHandler: teamHandler,
|
||||
TeamMembershipHandler: teamMembershipHandler,
|
||||
EndpointHandler: endpointHandler,
|
||||
EndpointGroupHandler: endpointGroupHandler,
|
||||
RegistryHandler: registryHandler,
|
||||
DockerHubHandler: dockerHubHandler,
|
||||
ResourceHandler: resourceHandler,
|
||||
@@ -114,6 +143,8 @@ func (server *Server) Start() error {
|
||||
WebSocketHandler: websocketHandler,
|
||||
FileHandler: fileHandler,
|
||||
UploadHandler: uploadHandler,
|
||||
ExtensionHandler: extensionHandler,
|
||||
StoridgeHandler: storidgeHandler,
|
||||
}
|
||||
|
||||
if server.SSL {
|
||||
|
||||
+99
-22
@@ -12,14 +12,19 @@ type (
|
||||
// CLIFlags represents the available flags on the CLI.
|
||||
CLIFlags struct {
|
||||
Addr *string
|
||||
AdminPassword *string
|
||||
AdminPasswordFile *string
|
||||
Assets *string
|
||||
CheckHealth *bool
|
||||
Data *string
|
||||
Endpoint *string
|
||||
ExternalEndpoints *string
|
||||
Labels *[]Pair
|
||||
Logo *string
|
||||
NoAuth *bool
|
||||
NoAnalytics *bool
|
||||
Templates *string
|
||||
TLSVerify *bool
|
||||
TLSSkipVerify *bool
|
||||
TLSCacert *string
|
||||
TLSCert *string
|
||||
TLSKey *string
|
||||
@@ -27,12 +32,6 @@ type (
|
||||
SSLCert *string
|
||||
SSLKey *string
|
||||
SyncInterval *string
|
||||
AdminPassword *string
|
||||
AdminPasswordFile *string
|
||||
// Deprecated fields
|
||||
Labels *[]Pair
|
||||
Logo *string
|
||||
Templates *string
|
||||
}
|
||||
|
||||
// Status represents the application status.
|
||||
@@ -154,7 +153,7 @@ type (
|
||||
URL string `json:"URL"`
|
||||
Authentication bool `json:"Authentication"`
|
||||
Username string `json:"Username"`
|
||||
Password string `json:"Password"`
|
||||
Password string `json:"Password,omitempty"`
|
||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||
}
|
||||
@@ -164,22 +163,28 @@ type (
|
||||
DockerHub struct {
|
||||
Authentication bool `json:"Authentication"`
|
||||
Username string `json:"Username"`
|
||||
Password string `json:"Password"`
|
||||
Password string `json:"Password,omitempty"`
|
||||
}
|
||||
|
||||
// EndpointID represents an endpoint identifier.
|
||||
EndpointID int
|
||||
|
||||
// EndpointType represents the type of an endpoint.
|
||||
EndpointType int
|
||||
|
||||
// Endpoint represents a Docker endpoint with all the info required
|
||||
// to connect to it.
|
||||
Endpoint struct {
|
||||
ID EndpointID `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
URL string `json:"URL"`
|
||||
PublicURL string `json:"PublicURL"`
|
||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||
ID EndpointID `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type EndpointType `json:"Type"`
|
||||
URL string `json:"URL"`
|
||||
GroupID EndpointGroupID `json:"GroupId"`
|
||||
PublicURL string `json:"PublicURL"`
|
||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||
Extensions []EndpointExtension `json:"Extensions"`
|
||||
|
||||
// Deprecated fields
|
||||
// Deprecated in DBVersion == 4
|
||||
@@ -189,6 +194,29 @@ type (
|
||||
TLSKeyPath string `json:"TLSKey,omitempty"`
|
||||
}
|
||||
|
||||
// EndpointGroupID represents an endpoint group identifier.
|
||||
EndpointGroupID int
|
||||
|
||||
// EndpointGroup represents a group of endpoints.
|
||||
EndpointGroup struct {
|
||||
ID EndpointGroupID `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Description string `json:"Description"`
|
||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||
Labels []Pair `json:"Labels"`
|
||||
}
|
||||
|
||||
// EndpointExtension represents a extension associated to an endpoint.
|
||||
EndpointExtension struct {
|
||||
Type EndpointExtensionType `json:"Type"`
|
||||
URL string `json:"URL"`
|
||||
}
|
||||
|
||||
// EndpointExtensionType represents the type of an endpoint extension. Only
|
||||
// one extension of each type can be associated to an endpoint.
|
||||
EndpointExtensionType int
|
||||
|
||||
// ResourceControlID represents a resource control identifier.
|
||||
ResourceControlID int
|
||||
|
||||
@@ -239,6 +267,7 @@ type (
|
||||
// DataStore defines the interface to manage the data.
|
||||
DataStore interface {
|
||||
Open() error
|
||||
Init() error
|
||||
Close() error
|
||||
MigrateData() error
|
||||
}
|
||||
@@ -292,6 +321,15 @@ type (
|
||||
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
|
||||
}
|
||||
|
||||
// EndpointGroupService represents a service for managing endpoint group data.
|
||||
EndpointGroupService interface {
|
||||
EndpointGroup(ID EndpointGroupID) (*EndpointGroup, error)
|
||||
EndpointGroups() ([]EndpointGroup, error)
|
||||
CreateEndpointGroup(group *EndpointGroup) error
|
||||
UpdateEndpointGroup(ID EndpointGroupID, group *EndpointGroup) error
|
||||
DeleteEndpointGroup(ID EndpointGroupID) error
|
||||
}
|
||||
|
||||
// RegistryService represents a service for managing registry data.
|
||||
RegistryService interface {
|
||||
Registry(ID RegistryID) (*Registry, error)
|
||||
@@ -345,6 +383,15 @@ type (
|
||||
CompareHashAndData(hash string, data string) error
|
||||
}
|
||||
|
||||
// DigitalSignatureService represents a service to manage digital signatures.
|
||||
DigitalSignatureService interface {
|
||||
ParseKeyPair(private, public []byte) error
|
||||
GenerateKeyPair() ([]byte, []byte, error)
|
||||
EncodedPublicKey() string
|
||||
PEMHeaders() (string, string)
|
||||
Sign(message string) (string, error)
|
||||
}
|
||||
|
||||
// JWTService represents a service for managing JWT tokens.
|
||||
JWTService interface {
|
||||
GenerateToken(data *TokenData) (string, error)
|
||||
@@ -360,13 +407,18 @@ type (
|
||||
DeleteTLSFile(folder string, fileType TLSFileType) error
|
||||
DeleteTLSFiles(folder string) error
|
||||
GetStackProjectPath(stackIdentifier string) string
|
||||
StoreStackFileFromString(stackIdentifier string, stackFileContent string) (string, error)
|
||||
StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error)
|
||||
StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error)
|
||||
StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error)
|
||||
KeyPairFilesExist() (bool, error)
|
||||
StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error
|
||||
LoadKeyPair() ([]byte, []byte, error)
|
||||
WriteJSONToFile(path string, content interface{}) error
|
||||
}
|
||||
|
||||
// GitService represents a service for managing Git.
|
||||
GitService interface {
|
||||
CloneRepository(url, destination string) error
|
||||
ClonePublicRepository(repositoryURL, destination string) error
|
||||
ClonePrivateRepositoryWithBasicAuth(repositoryURL, destination, username, password string) error
|
||||
}
|
||||
|
||||
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
|
||||
@@ -382,7 +434,7 @@ type (
|
||||
|
||||
// StackManager represents a service to manage stacks.
|
||||
StackManager interface {
|
||||
Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) error
|
||||
Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint)
|
||||
Logout(endpoint *Endpoint) error
|
||||
Deploy(stack *Stack, prune bool, endpoint *Endpoint) error
|
||||
Remove(stack *Stack, endpoint *Endpoint) error
|
||||
@@ -391,11 +443,22 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API.
|
||||
APIVersion = "1.16.0"
|
||||
APIVersion = "1.17.0"
|
||||
// DBVersion is the version number of the Portainer database.
|
||||
DBVersion = 7
|
||||
DBVersion = 10
|
||||
// DefaultTemplatesURL represents the default URL for the templates definitions.
|
||||
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
|
||||
// PortainerAgentHeader represents the name of the header available in any agent response
|
||||
PortainerAgentHeader = "Portainer-Agent"
|
||||
// PortainerAgentTargetHeader represent the name of the header containing the target node name.
|
||||
PortainerAgentTargetHeader = "X-PortainerAgent-Target"
|
||||
// PortainerAgentSignatureHeader represent the name of the header containing the digital signature
|
||||
PortainerAgentSignatureHeader = "X-PortainerAgent-Signature"
|
||||
// PortainerAgentPublicKeyHeader represent the name of the header containing the public key
|
||||
PortainerAgentPublicKeyHeader = "X-PortainerAgent-PublicKey"
|
||||
// PortainerAgentSignatureMessage represents the message used to create a digital signature
|
||||
// to be used when communicating with an agent
|
||||
PortainerAgentSignatureMessage = "Portainer-App"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -454,3 +517,17 @@ const (
|
||||
// ConfigResourceControl represents a resource control associated to a Docker config
|
||||
ConfigResourceControl
|
||||
)
|
||||
|
||||
const (
|
||||
_ EndpointExtensionType = iota
|
||||
// StoridgeEndpointExtension represents the Storidge extension
|
||||
StoridgeEndpointExtension
|
||||
)
|
||||
|
||||
const (
|
||||
_ EndpointType = iota
|
||||
// DockerEnvironment represents an endpoint connected to a Docker environment
|
||||
DockerEnvironment
|
||||
// AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment
|
||||
AgentOnDockerEnvironment
|
||||
)
|
||||
|
||||
+31
-9
@@ -56,7 +56,7 @@ info:
|
||||
|
||||
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
|
||||
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
title: "Portainer API"
|
||||
contact:
|
||||
email: "info@portainer.io"
|
||||
@@ -224,7 +224,7 @@ paths:
|
||||
**Access policy**: administrator
|
||||
operationId: "EndpointCreate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
- "multipart/form-data"
|
||||
produces:
|
||||
- "application/json"
|
||||
parameters:
|
||||
@@ -238,7 +238,7 @@ paths:
|
||||
200:
|
||||
description: "Success"
|
||||
schema:
|
||||
$ref: "#/definitions/EndpointCreateResponse"
|
||||
$ref: "#/definitions/Endpoint"
|
||||
400:
|
||||
description: "Invalid request"
|
||||
schema:
|
||||
@@ -2143,7 +2143,7 @@ definitions:
|
||||
description: "Is analytics enabled"
|
||||
Version:
|
||||
type: "string"
|
||||
example: "1.16.0"
|
||||
example: "1.17.0"
|
||||
description: "Portainer API version"
|
||||
PublicSettingsInspectResponse:
|
||||
type: "object"
|
||||
@@ -2455,6 +2455,15 @@ definitions:
|
||||
type: "boolean"
|
||||
example: false
|
||||
description: "Skip client verification when using TLS"
|
||||
TLSCACertFile:
|
||||
type: "file"
|
||||
description: "TLS CA certificate file"
|
||||
TLSCertFile:
|
||||
type: "file"
|
||||
description: "TLS client certificate file"
|
||||
TLSKeyFile:
|
||||
type: "file"
|
||||
description: "TLS client key file"
|
||||
EndpointCreateResponse:
|
||||
type: "object"
|
||||
properties:
|
||||
@@ -2603,12 +2612,13 @@ definitions:
|
||||
ResourceID:
|
||||
type: "string"
|
||||
example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08"
|
||||
description: "Docker resource identifier on which access control will be applied"
|
||||
description: "Docker resource identifier on which access control will be applied.\
|
||||
\ In the case of a resource control applied to a stack, use the stack name as identifier"
|
||||
Type:
|
||||
type: "string"
|
||||
example: "container"
|
||||
description: "Type of Docker resource. Valid values are: container, volume\
|
||||
\ or service"
|
||||
\ service, secret, config or stack"
|
||||
AdministratorsOnly:
|
||||
type: "boolean"
|
||||
example: true
|
||||
@@ -2904,14 +2914,26 @@ definitions:
|
||||
type: "string"
|
||||
example: "version: 3\n services:\n web:\n image:nginx"
|
||||
description: "Content of the Stack file. Required when using the 'string' deployment method."
|
||||
GitRepository:
|
||||
RepositoryURL:
|
||||
type: "string"
|
||||
example: "https://github.com/openfaas/faas"
|
||||
description: "URL of a public Git repository hosting the Stack file. Required when using the 'repository' deployment method."
|
||||
PathInRepository:
|
||||
description: "URL of a Git repository hosting the Stack file. Required when using the 'repository' deployment method."
|
||||
ComposeFilePathInRepository:
|
||||
type: "string"
|
||||
example: "docker-compose.yml"
|
||||
description: "Path to the Stack file inside the Git repository. Required when using the 'repository' deployment method."
|
||||
RepositoryAuthentication:
|
||||
type: "boolean"
|
||||
example: true
|
||||
description: "Use basic authentication to clone the Git repository."
|
||||
RepositoryUsername:
|
||||
type: "string"
|
||||
example: "myGitUsername"
|
||||
description: "Username used in basic authentication. Required when RepositoryAuthentication is true."
|
||||
RepositoryPassword:
|
||||
type: "string"
|
||||
example: "myGitPassword"
|
||||
description: "Password used in basic authentication. Required when RepositoryAuthentication is true."
|
||||
Env:
|
||||
type: "array"
|
||||
description: "A list of environment variables used during stack deployment"
|
||||
|
||||
+7
-60
@@ -6,71 +6,18 @@ angular.module('portainer', [
|
||||
'ngSanitize',
|
||||
'ngFileUpload',
|
||||
'ngMessages',
|
||||
'ngResource',
|
||||
'angularUtils.directives.dirPagination',
|
||||
'LocalStorageModule',
|
||||
'angular-jwt',
|
||||
'angular-google-analytics',
|
||||
'ui',
|
||||
'angular-json-tree',
|
||||
'angular-loading-bar',
|
||||
'angular-clipboard',
|
||||
'luegg.directives',
|
||||
'portainer.templates',
|
||||
'portainer.filters',
|
||||
'portainer.rest',
|
||||
'portainer.helpers',
|
||||
'portainer.services',
|
||||
'auth',
|
||||
'dashboard',
|
||||
'config',
|
||||
'configs',
|
||||
'container',
|
||||
'containerConsole',
|
||||
'containerLogs',
|
||||
'containerStats',
|
||||
'containerInspect',
|
||||
'serviceLogs',
|
||||
'containers',
|
||||
'createConfig',
|
||||
'createContainer',
|
||||
'createNetwork',
|
||||
'createRegistry',
|
||||
'createSecret',
|
||||
'createService',
|
||||
'createVolume',
|
||||
'createStack',
|
||||
'engine',
|
||||
'endpoint',
|
||||
'endpointAccess',
|
||||
'endpoints',
|
||||
'events',
|
||||
'portainer.app',
|
||||
'portainer.agent',
|
||||
'portainer.docker',
|
||||
'extension.storidge',
|
||||
'image',
|
||||
'images',
|
||||
'initAdmin',
|
||||
'initEndpoint',
|
||||
'main',
|
||||
'network',
|
||||
'networks',
|
||||
'node',
|
||||
'registries',
|
||||
'registry',
|
||||
'registryAccess',
|
||||
'secrets',
|
||||
'secret',
|
||||
'service',
|
||||
'services',
|
||||
'settings',
|
||||
'settingsAuthentication',
|
||||
'sidebar',
|
||||
'stack',
|
||||
'stacks',
|
||||
'swarm',
|
||||
'swarmVisualizer',
|
||||
'task',
|
||||
'team',
|
||||
'teams',
|
||||
'templates',
|
||||
'user',
|
||||
'users',
|
||||
'userSettings',
|
||||
'volume',
|
||||
'volumes',
|
||||
'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;
|
||||
}]);
|
||||
+7
-2
@@ -1,5 +1,6 @@
|
||||
angular.module('portainer')
|
||||
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar) {
|
||||
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper',
|
||||
function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar, $transitions, HttpRequestHelper) {
|
||||
'use strict';
|
||||
|
||||
EndpointProvider.initialize();
|
||||
@@ -27,6 +28,10 @@ angular.module('portainer')
|
||||
originalSet.apply(cfpLoadingBar, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
$transitions.onBefore({ to: 'docker.**' }, function() {
|
||||
HttpRequestHelper.resetAgentTargetQueue();
|
||||
});
|
||||
}]);
|
||||
|
||||
|
||||
@@ -35,7 +40,7 @@ function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
||||
authManager.redirectWhenUnauthenticated();
|
||||
Authentication.init();
|
||||
$rootScope.$on('tokenHasExpired', function() {
|
||||
$state.go('auth', {error: 'Your session has expired'});
|
||||
$state.go('portainer.auth', {error: 'Your session has expired'});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
angular.module('containerLogs', [])
|
||||
.controller('ContainerLogsController', ['$scope', '$transition$', '$anchorScroll', 'ContainerLogs', 'Container', 'Notifications',
|
||||
function ($scope, $transition$, $anchorScroll, ContainerLogs, Container, Notifications) {
|
||||
$scope.state = {};
|
||||
$scope.state.displayTimestampsOut = false;
|
||||
$scope.state.displayTimestampsErr = false;
|
||||
$scope.stdout = '';
|
||||
$scope.stderr = '';
|
||||
$scope.tailLines = 2000;
|
||||
|
||||
Container.get({id: $transition$.params().id}, function (d) {
|
||||
$scope.container = d;
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'Unable to retrieve container info');
|
||||
});
|
||||
|
||||
function getLogs() {
|
||||
getLogsStdout();
|
||||
getLogsStderr();
|
||||
}
|
||||
|
||||
function getLogsStderr() {
|
||||
ContainerLogs.get($transition$.params().id, {
|
||||
stdout: 0,
|
||||
stderr: 1,
|
||||
timestamps: $scope.state.displayTimestampsErr,
|
||||
tail: $scope.tailLines
|
||||
}, function (data, status, headers, config) {
|
||||
// Replace carriage returns with newlines to clean up output
|
||||
data = data.replace(/[\r]/g, '\n');
|
||||
// Strip 8 byte header from each line of output
|
||||
data = data.substring(8);
|
||||
data = data.replace(/\n(.{8})/g, '\n');
|
||||
$scope.stderr = data;
|
||||
});
|
||||
}
|
||||
|
||||
function getLogsStdout() {
|
||||
ContainerLogs.get($transition$.params().id, {
|
||||
stdout: 1,
|
||||
stderr: 0,
|
||||
timestamps: $scope.state.displayTimestampsOut,
|
||||
tail: $scope.tailLines
|
||||
}, function (data, status, headers, config) {
|
||||
// Replace carriage returns with newlines to clean up output
|
||||
data = data.replace(/[\r]/g, '\n');
|
||||
// Strip 8 byte header from each line of output
|
||||
data = data.substring(8);
|
||||
data = data.replace(/\n(.{8})/g, '\n');
|
||||
$scope.stdout = data;
|
||||
});
|
||||
}
|
||||
|
||||
// initial call
|
||||
getLogs();
|
||||
var logIntervalId = window.setInterval(getLogs, 5000);
|
||||
|
||||
$scope.$on('$destroy', function () {
|
||||
// clearing interval when view changes
|
||||
clearInterval(logIntervalId);
|
||||
});
|
||||
|
||||
$scope.toggleTimestampsOut = function () {
|
||||
getLogsStdout();
|
||||
};
|
||||
|
||||
$scope.toggleTimestampsErr = function () {
|
||||
getLogsStderr();
|
||||
};
|
||||
}]);
|
||||
@@ -1,54 +0,0 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Container logs"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Logs
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="widget-icon grey pull-left">
|
||||
<i class="fa fa-server"></i>
|
||||
</div>
|
||||
<div class="title">{{ container.Name|trimcontainername }}</div>
|
||||
<div class="comment">Name</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-info-circle" title="Stdout logs"></rd-widget-header>
|
||||
<rd-widget-taskbar>
|
||||
<input type="checkbox" ng-model="state.displayTimestampsOut" id="displayAllTsOut" ng-change="toggleTimestampsOut()"/>
|
||||
<label for="displayAllTsOut">Display timestamps</label>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="panel-body">
|
||||
<pre id="stdoutLog" class="pre-scrollable pre-x-scrollable">{{stdout}}</pre>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-exclamation-triangle" title="Stderr logs"></rd-widget-header>
|
||||
<rd-widget-taskbar>
|
||||
<input type="checkbox" ng-model="state.displayTimestampsErr" id="displayAllTsErr" ng-change="toggleTimestampsErr()"/>
|
||||
<label for="displayAllTsErr">Display timestamps</label>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="panel-body">
|
||||
<pre id="stderrLog" class="pre-scrollable pre-x-scrollable">{{stderr}}</pre>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,92 +0,0 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Endpoints">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="endpoints" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Endpoint management</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="!applicationState.application.endpointManagement">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-exclamation-triangle" title="Endpoint management is not available">
|
||||
</rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<span class="small text-muted">Portainer has been started using the <code>--external-endpoints</code> flag.
|
||||
Endpoint management via the UI is disabled.
|
||||
<span ng-if="applicationState.application.authentication">You can still manage endpoint access.</span>
|
||||
</span>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="applicationState.application.endpointManagement">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-plus" title="Add a new endpoint">
|
||||
</rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="container_name" ng-model="formValues.Name" placeholder="e.g. docker-prod01">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- endpoint-url-input -->
|
||||
<div class="form-group">
|
||||
<label for="endpoint_url" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Endpoint URL
|
||||
<portainer-tooltip position="bottom" message="URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it."></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="endpoint_url" ng-model="formValues.URL" placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !endpoint-url-input -->
|
||||
<!-- endpoint-public-url-input -->
|
||||
<div class="form-group">
|
||||
<label for="endpoint_public_url" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Public IP
|
||||
<portainer-tooltip position="bottom" message="URL or IP address where exposed containers will be reachable. This field is optional and will default to the endpoint URL."></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="endpoint_public_url" ng-model="formValues.PublicURL" placeholder="e.g. 10.0.0.10 or mydocker.mydomain.com">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !endpoint-public-url-input -->
|
||||
<!-- endpoint-security -->
|
||||
<por-endpoint-security form-data="formValues.SecurityFormData"></por-endpoint-security>
|
||||
<!-- !endpoint-security -->
|
||||
<!-- actions -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Name || !formValues.URL || (formValues.TLS && ((formValues.TLSVerify && !formValues.TLSCACert) || (formValues.TLSClientCert && (!formValues.TLSCert || !formValues.TLSKey))))" ng-click="addEndpoint()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress"><i class="fa fa-plus" aria-hidden="true"></i> Add endpoint</span>
|
||||
<span ng-show="state.actionInProgress">Creating endpoint...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<endpoints-datatable
|
||||
title="Endpoints" title-icon="fa-plug"
|
||||
dataset="endpoints" table-key="endpoints"
|
||||
order-by="Name" show-text-filter="true"
|
||||
endpoint-management="applicationState.application.endpointManagement"
|
||||
access-management="applicationState.application.authentication"
|
||||
remove-action="removeAction"
|
||||
></endpoints-datatable>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,81 +0,0 @@
|
||||
angular.module('endpoints', [])
|
||||
.controller('EndpointsController', ['$scope', '$state', '$filter', 'EndpointService', 'Notifications',
|
||||
function ($scope, $state, $filter, EndpointService, Notifications) {
|
||||
$scope.state = {
|
||||
uploadInProgress: false,
|
||||
actionInProgress: false
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
Name: '',
|
||||
URL: '',
|
||||
PublicURL: '',
|
||||
SecurityFormData: new EndpointSecurityFormData()
|
||||
};
|
||||
|
||||
$scope.addEndpoint = function() {
|
||||
var name = $scope.formValues.Name;
|
||||
var URL = $filter('stripprotocol')($scope.formValues.URL);
|
||||
var PublicURL = $scope.formValues.PublicURL;
|
||||
if (PublicURL === '') {
|
||||
PublicURL = URL.split(':')[0];
|
||||
}
|
||||
|
||||
var securityData = $scope.formValues.SecurityFormData;
|
||||
var TLS = securityData.TLS;
|
||||
var TLSMode = securityData.TLSMode;
|
||||
var TLSSkipVerify = TLS && (TLSMode === 'tls_client_noca' || TLSMode === 'tls_only');
|
||||
var TLSSkipClientVerify = TLS && (TLSMode === 'tls_ca' || TLSMode === 'tls_only');
|
||||
var TLSCAFile = TLSSkipVerify ? null : securityData.TLSCACert;
|
||||
var TLSCertFile = TLSSkipClientVerify ? null : securityData.TLSCert;
|
||||
var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey;
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) {
|
||||
Notifications.success('Endpoint created', name);
|
||||
$state.reload();
|
||||
}, function error(err) {
|
||||
$scope.state.uploadInProgress = false;
|
||||
$scope.state.actionInProgress = false;
|
||||
Notifications.error('Failure', err, 'Unable to create endpoint');
|
||||
}, function update(evt) {
|
||||
if (evt.upload) {
|
||||
$scope.state.uploadInProgress = evt.upload;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.removeAction = function (selectedItems) {
|
||||
var actionCount = selectedItems.length;
|
||||
angular.forEach(selectedItems, function (endpoint) {
|
||||
EndpointService.deleteEndpoint(endpoint.Id)
|
||||
.then(function success() {
|
||||
Notifications.success('Endpoint successfully removed', endpoint.Name);
|
||||
var index = $scope.endpoints.indexOf(endpoint);
|
||||
$scope.endpoints.splice(index, 1);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove endpoint');
|
||||
})
|
||||
.finally(function final() {
|
||||
--actionCount;
|
||||
if (actionCount === 0) {
|
||||
$state.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function fetchEndpoints() {
|
||||
EndpointService.endpoints()
|
||||
.then(function success(data) {
|
||||
$scope.endpoints = data;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
|
||||
$scope.endpoints = [];
|
||||
});
|
||||
}
|
||||
|
||||
fetchEndpoints();
|
||||
}]);
|
||||
@@ -1,144 +0,0 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Network list">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="networks" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Networks</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<networks-datatable
|
||||
title="Networks" title-icon="fa-sitemap"
|
||||
dataset="networks" table-key="networks"
|
||||
order-by="Name" show-text-filter="true"
|
||||
remove-action="removeAction"
|
||||
show-ownership-column="applicationState.application.authentication"
|
||||
></networks-datatable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-sitemap" title="Networks">
|
||||
<div class="pull-right">
|
||||
Items per page:
|
||||
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
|
||||
<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>
|
||||
</div>
|
||||
</rd-widget-header>
|
||||
<rd-widget-taskbar classes="col-lg-12">
|
||||
<div class="pull-left">
|
||||
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
|
||||
<a class="btn btn-primary" type="button" ui-sref="actions.create.network"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add network</a>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
|
||||
</div>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="order('Name')">
|
||||
Name
|
||||
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="order('StackName')">
|
||||
Stack
|
||||
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="order('Scope')">
|
||||
Scope
|
||||
<span ng-show="sortType == 'Scope' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Scope' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="order('Driver')">
|
||||
Driver
|
||||
<span ng-show="sortType == 'Driver' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Driver' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="order('IPAM.Driver')">
|
||||
IPAM Driver
|
||||
<span ng-show="sortType == 'IPAM.Driver' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'IPAM.Driver' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="order('IPAM.Config[0].Subnet')">
|
||||
IPAM Subnet
|
||||
<span ng-show="sortType == 'IPAM.Config[0].Subnet' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'IPAM.Config[0].Subnet' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="order('IPAM.Config[0].Gateway')">
|
||||
IPAM Gateway
|
||||
<span ng-show="sortType == 'IPAM.Config[0].Gateway' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'IPAM.Config[0].Gateway' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="applicationState.application.authentication">
|
||||
<a ng-click="order('ResourceControl.Ownership')">
|
||||
Ownership
|
||||
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr dir-paginate="network in ( state.filteredNetworks = (networks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))" ng-class="{active: network.Checked}">
|
||||
<td><input type="checkbox" ng-model="network.Checked" ng-change="selectItem(network)"/></td>
|
||||
<td><a ui-sref="network({id: network.Id})">{{ network.Name | truncate:40 }}</a></td>
|
||||
<td>{{ network.StackName ? network.StackName : '-' }}</td>
|
||||
<td>{{ network.Scope }}</td>
|
||||
<td>{{ network.Driver }}</td>
|
||||
<td>{{ network.IPAM.Driver }}</td>
|
||||
<td>{{ network.IPAM.Config[0].Subnet ? network.IPAM.Config[0].Subnet : '-' }}</td>
|
||||
<td>{{ network.IPAM.Config[0].Gateway ? network.IPAM.Config[0].Gateway : '-' }}</td>
|
||||
<td ng-if="applicationState.application.authentication">
|
||||
<span>
|
||||
<i ng-class="network.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
||||
{{ network.ResourceControl.Ownership ? network.ResourceControl.Ownership : network.ResourceControl.Ownership = 'public' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!networks">
|
||||
<td colspan="9" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="networks.length == 0">
|
||||
<td colspan="9" class="text-center text-muted">No networks available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div ng-if="networks" class="pull-left pagination-controls">
|
||||
<dir-pagination-controls></dir-pagination-controls>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div> -->
|
||||
@@ -1,78 +0,0 @@
|
||||
angular.module('serviceLogs', [])
|
||||
.controller('ServiceLogsController', ['$scope', '$transition$', '$anchorScroll', 'ServiceLogs', 'Service',
|
||||
function ($scope, $transition$, $anchorScroll, ServiceLogs, Service) {
|
||||
$scope.state = {};
|
||||
$scope.state.displayTimestampsOut = false;
|
||||
$scope.state.displayTimestampsErr = false;
|
||||
$scope.stdout = '';
|
||||
$scope.stderr = '';
|
||||
$scope.tailLines = 2000;
|
||||
|
||||
function getLogs() {
|
||||
getLogsStdout();
|
||||
getLogsStderr();
|
||||
}
|
||||
|
||||
function getLogsStderr() {
|
||||
ServiceLogs.get($transition$.params().id, {
|
||||
stdout: 0,
|
||||
stderr: 1,
|
||||
timestamps: $scope.state.displayTimestampsErr,
|
||||
tail: $scope.tailLines
|
||||
}, function (data, status, headers, config) {
|
||||
// Replace carriage returns with newlines to clean up output
|
||||
data = data.replace(/[\r]/g, '\n');
|
||||
// Strip 8 byte header from each line of output
|
||||
data = data.substring(8);
|
||||
data = data.replace(/\n(.{8})/g, '\n');
|
||||
$scope.stderr = data;
|
||||
});
|
||||
}
|
||||
|
||||
function getLogsStdout() {
|
||||
ServiceLogs.get($transition$.params().id, {
|
||||
stdout: 1,
|
||||
stderr: 0,
|
||||
timestamps: $scope.state.displayTimestampsOut,
|
||||
tail: $scope.tailLines
|
||||
}, function (data, status, headers, config) {
|
||||
// Replace carriage returns with newlines to clean up output
|
||||
data = data.replace(/[\r]/g, '\n');
|
||||
// Strip 8 byte header from each line of output
|
||||
data = data.substring(8);
|
||||
data = data.replace(/\n(.{8})/g, '\n');
|
||||
$scope.stdout = data;
|
||||
});
|
||||
}
|
||||
|
||||
function getService() {
|
||||
Service.get({id: $transition$.params().id}, function (d) {
|
||||
$scope.service = d;
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'Unable to retrieve service info');
|
||||
});
|
||||
}
|
||||
|
||||
function initView() {
|
||||
getService();
|
||||
getLogs();
|
||||
|
||||
var logIntervalId = window.setInterval(getLogs, 5000);
|
||||
|
||||
$scope.$on('$destroy', function () {
|
||||
// clearing interval when view changes
|
||||
clearInterval(logIntervalId);
|
||||
});
|
||||
|
||||
$scope.toggleTimestampsOut = function () {
|
||||
getLogsStdout();
|
||||
};
|
||||
|
||||
$scope.toggleTimestampsErr = function () {
|
||||
getLogsStderr();
|
||||
};
|
||||
}
|
||||
|
||||
initView();
|
||||
|
||||
}]);
|
||||
@@ -1,54 +0,0 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Service logs"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="services">Services</a> > <a ui-sref="service({id: service.ID})">{{ service.Spec.Name }}</a> > Logs
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="widget-icon grey pull-left">
|
||||
<i class="fa fa-list-alt"></i>
|
||||
</div>
|
||||
<div class="title">{{ service.Spec.Name }}</div>
|
||||
<div class="comment">Name</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-info-circle" title="Stdout logs"></rd-widget-header>
|
||||
<rd-widget-taskbar>
|
||||
<input type="checkbox" ng-model="state.displayTimestampsOut" id="displayAllTsOut" ng-change="toggleTimestampsOut()"/>
|
||||
<label for="displayAllTsOut">Display timestamps</label>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="panel-body">
|
||||
<pre id="stdoutLog" class="pre-scrollable pre-x-scrollable">{{stdout}}</pre>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-exclamation-triangle" title="Stderr logs"></rd-widget-header>
|
||||
<rd-widget-taskbar>
|
||||
<input type="checkbox" ng-model="state.displayTimestampsErr" id="displayAllTsErr" ng-change="toggleTimestampsErr()"/>
|
||||
<label for="displayAllTsErr">Display timestamps</label>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="panel-body">
|
||||
<pre id="stderrLog" class="pre-scrollable pre-x-scrollable">{{stderr}}</pre>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,102 +0,0 @@
|
||||
<!-- Sidebar -->
|
||||
<div id="sidebar-wrapper">
|
||||
<div class="sidebar-header">
|
||||
<a ng-click="toggleSidebar()" class="interactive">
|
||||
<img ng-if="logo" ng-src="{{ logo }}" class="img-responsive logo">
|
||||
<img ng-if="!logo" src="images/logo.png" class="img-responsive logo" alt="Portainer">
|
||||
<span class="menu-icon glyphicon glyphicon-transfer"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<ul class="sidebar">
|
||||
<li class="sidebar-title"><span>Active endpoint</span></li>
|
||||
<li class="sidebar-title">
|
||||
<select class="select-endpoint form-control" ng-options="endpoint.Name for endpoint in endpoints" ng-model="activeEndpoint" ng-change="switchEndpoint(activeEndpoint)">
|
||||
</select>
|
||||
</li>
|
||||
<li class="sidebar-title"><span>Endpoint actions</span></li>
|
||||
<li class="sidebar-list">
|
||||
<a ui-sref="dashboard" ui-sref-active="active">Dashboard <span class="menu-icon fa fa-tachometer fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
<a ui-sref="templates" ui-sref-active="active">App Templates <span class="menu-icon fa fa-rocket fa-fw"></span></a>
|
||||
<div class="sidebar-sublist" ng-if="toggle && displayExternalContributors && ($state.current.name === 'templates' || $state.current.name === 'templates_linuxserver')">
|
||||
<a ui-sref="templates_linuxserver" ui-sref-active="active">LinuxServer.io</a>
|
||||
</div>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<a ui-sref="stacks" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<a ui-sref="services" ui-sref-active="active">Services <span class="menu-icon fa fa-list-alt fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
<a ui-sref="containers" ui-sref-active="active">Containers <span class="menu-icon fa fa-server fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
<a ui-sref="images" ui-sref-active="active">Images <span class="menu-icon fa fa-clone fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
<a ui-sref="networks" ui-sref-active="active">Networks <span class="menu-icon fa fa-sitemap fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
<a ui-sref="volumes" ui-sref-active="active">Volumes <span class="menu-icon fa fa-cubes fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.30 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<a ui-sref="configs" ui-sref-active="active">Configs <span class="menu-icon fa fa-file-code-o fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<a ui-sref="secrets" ui-sref-active="active">Secrets <span class="menu-icon fa fa-user-secret fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="(applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC') && (!applicationState.application.authentication || isAdmin)">
|
||||
<a ui-sref="events" ui-sref-active="active">Events <span class="menu-icon fa fa-history fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || (applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER')">
|
||||
<a ui-sref="swarm" ui-sref-active="active">Swarm <span class="menu-icon fa fa-object-group fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'">
|
||||
<a ui-sref="engine" ui-sref-active="active">Engine <span class="menu-icon fa fa-th fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-title" ng-if="applicationState.endpoint.extensions.length > 0 && isAdmin && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<span>Extensions</span>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="applicationState.endpoint.extensions.indexOf('storidge') !== -1 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<a ui-sref="storidge.cluster" ui-sref-active="active">Storidge <span class="menu-icon fa fa-bolt fa-fw"></span></a>
|
||||
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'storidge.cluster' || $state.current.name === 'storidge.profiles' || $state.current.name === 'storidge.monitor' || $state.current.name === 'storidge.profiles.create' || $state.current.name === 'storidge.profiles.edit')">
|
||||
<a ui-sref="storidge.monitor" ui-sref-active="active">Monitor</a>
|
||||
</div>
|
||||
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'storidge.cluster' || $state.current.name === 'storidge.profiles' || $state.current.name === 'storidge.monitor' || $state.current.name === 'storidge.profiles.create' || $state.current.name === 'storidge.profiles.edit')">
|
||||
<a ui-sref="storidge.profiles" ui-sref-active="active">Profiles</a>
|
||||
</div>
|
||||
</li>
|
||||
<li class="sidebar-title" ng-if="!applicationState.application.authentication || isAdmin || isTeamLeader">
|
||||
<span>Portainer settings</span>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="applicationState.application.authentication && (isAdmin || isTeamLeader)">
|
||||
<a ui-sref="users" ui-sref-active="active">User management <span class="menu-icon fa fa-users fa-fw"></span></a>
|
||||
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'users' || $state.current.name === 'user' || $state.current.name === 'teams' || $state.current.name === 'team')">
|
||||
<a ui-sref="teams" ui-sref-active="active">Teams</a>
|
||||
</div>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
|
||||
<a ui-sref="endpoints" ui-sref-active="active">Endpoints <span class="menu-icon fa fa-plug fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
|
||||
<a ui-sref="registries" ui-sref-active="active">Registries <span class="menu-icon fa fa-database fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
|
||||
<a ui-sref="settings" ui-sref-active="active">Settings <span class="menu-icon fa fa-cogs fa-fw"></span></a>
|
||||
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'settings' || $state.current.name === 'settings_authentication' || $state.current.name === 'settings_about') && applicationState.application.authentication && isAdmin">
|
||||
<a ui-sref="settings_authentication" ui-sref-active="active">Authentication</a></div>
|
||||
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'settings' || $state.current.name === 'settings_authentication' || $state.current.name === 'settings_about')">
|
||||
<a ui-sref="settings_about" ui-sref-active="active">About</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="sidebar-footer-content">
|
||||
<img src="images/logo_small.png" class="img-responsive logo" alt="Portainer">
|
||||
<span class="version">{{ uiVersion }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Sidebar -->
|
||||
@@ -1,73 +0,0 @@
|
||||
angular.module('sidebar', [])
|
||||
.controller('SidebarController', ['$q', '$scope', '$state', 'Settings', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService', 'ExtensionManager',
|
||||
function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointProvider, Notifications, Authentication, UserService, ExtensionManager) {
|
||||
|
||||
$scope.uiVersion = StateManager.getState().application.version;
|
||||
$scope.displayExternalContributors = StateManager.getState().application.displayExternalContributors;
|
||||
$scope.logo = StateManager.getState().application.logo;
|
||||
$scope.endpoints = [];
|
||||
|
||||
$scope.switchEndpoint = function(endpoint) {
|
||||
var activeEndpointID = EndpointProvider.endpointID();
|
||||
var activeEndpointPublicURL = EndpointProvider.endpointPublicURL();
|
||||
EndpointProvider.setEndpointID(endpoint.Id);
|
||||
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
|
||||
|
||||
StateManager.updateEndpointState(true)
|
||||
.then(function success() {
|
||||
ExtensionManager.reset();
|
||||
$state.go('dashboard');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
|
||||
EndpointProvider.setEndpointID(activeEndpointID);
|
||||
EndpointProvider.setEndpointPublicURL(activeEndpointPublicURL);
|
||||
StateManager.updateEndpointState(true)
|
||||
.then(function success() {});
|
||||
});
|
||||
};
|
||||
|
||||
function setActiveEndpoint(endpoints) {
|
||||
var activeEndpointID = EndpointProvider.endpointID();
|
||||
angular.forEach(endpoints, function (endpoint) {
|
||||
if (endpoint.Id === activeEndpointID) {
|
||||
$scope.activeEndpoint = endpoint;
|
||||
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkPermissions(memberships) {
|
||||
var isLeader = false;
|
||||
angular.forEach(memberships, function(membership) {
|
||||
if (membership.Role === 1) {
|
||||
isLeader = true;
|
||||
}
|
||||
});
|
||||
$scope.isTeamLeader = isLeader;
|
||||
}
|
||||
|
||||
function initView() {
|
||||
EndpointService.endpoints()
|
||||
.then(function success(data) {
|
||||
var endpoints = data;
|
||||
$scope.endpoints = _.sortBy(endpoints, ['Name']);
|
||||
setActiveEndpoint(endpoints);
|
||||
|
||||
if (StateManager.getState().application.authentication) {
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
var isAdmin = userDetails.role === 1 ? true: false;
|
||||
$scope.isAdmin = isAdmin;
|
||||
return $q.when(!isAdmin ? UserService.userMemberships(userDetails.ID) : []);
|
||||
}
|
||||
})
|
||||
.then(function success(data) {
|
||||
checkPermissions(data);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
@@ -1,91 +0,0 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Cluster overview">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="swarm" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Swarm</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-object-group" title="Cluster status"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Nodes</td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ swarm.Nodes }}</td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">{{ info.Swarm.Nodes }}</td>
|
||||
</tr>
|
||||
<tr ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
|
||||
<td>Images</td>
|
||||
<td>{{ info.Images }}</td>
|
||||
</tr>
|
||||
<tr ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
|
||||
<td>Swarm version</td>
|
||||
<td>{{ docker.Version|swarmversion }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Docker API version</td>
|
||||
<td>{{ docker.ApiVersion }}</td>
|
||||
</tr>
|
||||
<tr ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
|
||||
<td>Strategy</td>
|
||||
<td>{{ swarm.Strategy }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Total CPU</td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ info.NCPU }}</td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">{{ totalCPU }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Total memory</td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ info.MemTotal|humansize: 2 }}</td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">{{ totalMemory|humansize: 2 }}</td>
|
||||
</tr>
|
||||
<tr ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
|
||||
<td>Operating system</td>
|
||||
<td>{{ info.OperatingSystem }}</td>
|
||||
</tr>
|
||||
<tr ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
|
||||
<td>Kernel version</td>
|
||||
<td>{{ info.KernelVersion }}</td>
|
||||
</tr>
|
||||
<tr ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
|
||||
<td>Go version</td>
|
||||
<td>{{ docker.GoVersion }}</td>
|
||||
</tr>
|
||||
<tr ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
|
||||
<td colspan="2">
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
<a ui-sref="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
|
||||
<nodes-ss-datatable
|
||||
title="Nodes" title-icon="fa-hdd-o"
|
||||
dataset="swarm.Status" table-key="nodes"
|
||||
order-by="name" show-text-filter="true"
|
||||
></nodes-ss-datatable>
|
||||
</div>
|
||||
<div class="col-sm-12" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
|
||||
<nodes-datatable
|
||||
title="Nodes" title-icon="fa-hdd-o"
|
||||
dataset="nodes" table-key="nodes"
|
||||
order-by="Hostname" show-text-filter="true"
|
||||
show-ip-address-column="applicationState.endpoint.apiVersion >= 1.25"
|
||||
access-to-node-details="!applicationState.application.authentication || isAdmin"
|
||||
></nodes-datatable>
|
||||
</div>
|
||||
</div>
|
||||
+19
-4
@@ -1,6 +1,6 @@
|
||||
angular.module('portainer')
|
||||
.config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', 'cfpLoadingBarProvider',
|
||||
function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) {
|
||||
.config(['$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', 'cfpLoadingBarProvider',
|
||||
function ($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) {
|
||||
'use strict';
|
||||
|
||||
var environment = '@@ENVIRONMENT';
|
||||
@@ -16,16 +16,32 @@ angular.module('portainer')
|
||||
return LocalStorage.getJWT();
|
||||
}],
|
||||
unauthenticatedRedirector: ['$state', function($state) {
|
||||
$state.go('auth', {error: 'Your session has expired'});
|
||||
$state.go('portainer.auth', {error: 'Your session has expired'});
|
||||
}]
|
||||
});
|
||||
$httpProvider.interceptors.push('jwtInterceptor');
|
||||
$httpProvider.defaults.headers.post['Content-Type'] = 'application/json';
|
||||
$httpProvider.defaults.headers.put['Content-Type'] = 'application/json';
|
||||
$httpProvider.defaults.headers.patch['Content-Type'] = 'application/json';
|
||||
|
||||
$httpProvider.interceptors.push(['HttpRequestHelper', function(HttpRequestHelper) {
|
||||
return {
|
||||
request: function(config) {
|
||||
if (config.url.indexOf('/docker/') > -1) {
|
||||
config.headers['X-PortainerAgent-Target'] = HttpRequestHelper.portainerAgentTargetHeader();
|
||||
}
|
||||
return config;
|
||||
}
|
||||
};
|
||||
}]);
|
||||
|
||||
AnalyticsProvider.setAccount('@@CONFIG_GA_ID');
|
||||
AnalyticsProvider.startOffline(true);
|
||||
|
||||
toastr.options.timeOut = 3000;
|
||||
|
||||
Terminal.applyAddon(fit);
|
||||
|
||||
$uibTooltipProvider.setTriggers({
|
||||
'mouseenter': 'mouseleave',
|
||||
'click': 'click',
|
||||
@@ -37,5 +53,4 @@ angular.module('portainer')
|
||||
cfpLoadingBarProvider.parentSelector = '#loadingbar-placeholder';
|
||||
|
||||
$urlRouterProvider.otherwise('/auth');
|
||||
configureRoutes($stateProvider);
|
||||
}]);
|
||||
|
||||
@@ -2,6 +2,7 @@ angular.module('portainer')
|
||||
.constant('API_ENDPOINT_AUTH', 'api/auth')
|
||||
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
|
||||
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
|
||||
.constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups')
|
||||
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
|
||||
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
|
||||
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
angular.module('portainer').component('porAccessManagement', {
|
||||
templateUrl: 'app/directives/accessManagement/porAccessManagement.html',
|
||||
controller: 'porAccessManagementController',
|
||||
bindings: {
|
||||
accessControlledEntity: '<',
|
||||
updateAccess: '&'
|
||||
}
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<rd-widget>
|
||||
<rd-widget-header classes="col-sm-12 col-md-6 nopadding" icon="fa-users" title="Users and teams">
|
||||
<div class="pull-md-right pull-lg-right">
|
||||
Items per page:
|
||||
<select ng-model="$ctrl.state.pagination_count_accesses" ng-change="$ctrl.changePaginationCountAccesses()">
|
||||
<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>
|
||||
</div>
|
||||
</rd-widget-header>
|
||||
<rd-widget-taskbar classes="col-sm-12 nopadding">
|
||||
<div class="col-sm-12 col-md-6 nopadding">
|
||||
<button class="btn btn-primary btn-sm" ng-click="$ctrl.authorizeAllAccesses()" ng-disabled="$ctrl.accesses.length === 0 || $ctrl.filteredUsers.length === 0"><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Authorize all</button>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6 nopadding">
|
||||
<input type="text" id="filter" ng-model="$ctrl.state.filterUsers" placeholder="Filter..." class="form-control input-sm" />
|
||||
</div>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<a ng-click="$ctrl.orderAccesses('Name')">
|
||||
Name
|
||||
<span ng-show="$ctrl.state.sortAccessesBy == 'Name' && !$ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="$ctrl.state.sortAccessesBy == 'Name' && $ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.orderAccesses('Type')">
|
||||
Type
|
||||
<span ng-show="$ctrl.state.sortAccessesBy == 'Type' && !$ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="$ctrl.state.sortAccessesBy == 'Type' && $ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-click="$ctrl.authorizeAccess(user)" class="interactive" dir-paginate="user in $ctrl.accesses | filter:$ctrl.state.filterUsers | orderBy:$ctrl.state.sortAccessesBy:$ctrl.state.sortAccessesReverse | itemsPerPage: $ctrl.state.pagination_count_accesses">
|
||||
<td>{{ user.Name }}</td>
|
||||
<td>
|
||||
<i class="fa" ng-class="user.Type === 'user' ? 'fa-user' : 'fa-users'" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
{{ user.Type }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.accesses">
|
||||
<td colspan="2" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.accesses.length === 0 || ($ctrl.accesses | filter:$ctrl.state.filterUsers | orderBy:$ctrl.state.sortAccessesBy:$ctrl.state.sortAccessesReverse | itemsPerPage: $ctrl.state.pagination_count_accesses).length === 0">
|
||||
<td colspan="2" class="text-center text-muted">No user or team available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div ng-if="$ctrl.accesses" class="pull-left pagination-controls">
|
||||
<dir-pagination-controls></dir-pagination-controls>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<rd-widget>
|
||||
<rd-widget-header classes="col-sm-12 col-md-6 nopadding" icon="fa-users" title="Authorized users and teams">
|
||||
<div class="pull-md-right pull-lg-right">
|
||||
Items per page:
|
||||
<select ng-model="$ctrl.state.pagination_count_authorizedAccesses" ng-change="$ctrl.changePaginationCountAuthorizedAccesses()">
|
||||
<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>
|
||||
</div>
|
||||
</rd-widget-header>
|
||||
<rd-widget-taskbar classes="col-sm-12 nopadding">
|
||||
<div class="col-sm-12 col-md-6 nopadding">
|
||||
<button class="btn btn-primary btn-sm" ng-click="$ctrl.unauthorizeAllAccesses()" ng-disabled="$ctrl.authorizedAccesses.length === 0 || $ctrl.filteredAuthorizedUsers.length === 0"><i class="fa fa-user-times space-right" aria-hidden="true"></i>Deny all</button>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6 nopadding">
|
||||
<input type="text" id="filter" ng-model="$ctrl.state.filterAuthorizedUsers" placeholder="Filter..." class="form-control input-sm" />
|
||||
</div>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<a ng-click="$ctrl.orderAuthorizedAccesses('Name')">
|
||||
Name
|
||||
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Name' && !$ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Name' && $ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.orderAuthorizedAccesses('Type')">
|
||||
Type
|
||||
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Type' && !$ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Type' && $ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-click="$ctrl.unauthorizeAccess(user)" class="interactive" pagination-id="table_authaccess" dir-paginate="user in $ctrl.authorizedAccesses | filter:$ctrl.state.filterAuthorizedUsers | orderBy:$ctrl.state.sortAuthorizedAccessesBy:$ctrl.state.sortAuthorizedAccessesReverse | itemsPerPage: $ctrl.state.pagination_count_authorizedAccesses">
|
||||
<td>{{ user.Name }}</td>
|
||||
<td>
|
||||
<i class="fa" ng-class="user.Type === 'user' ? 'fa-user' : 'fa-users'" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
{{ user.Type }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.authorizedAccesses">
|
||||
<td colspan="2" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.authorizedAccesses.length === 0 || (authorizedAccesses | filter:state.filterAuthorizedUsers | orderBy:sortTypeAuthorizedAccesses:sortReverseAuthorizedAccesses | itemsPerPage: state.pagination_count_authorizedAccesses).length === 0">
|
||||
<td colspan="2" class="text-center text-muted">No authorized user or team.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div ng-if="$ctrl.authorizedAccesses" class="pull-left pagination-controls">
|
||||
<dir-pagination-controls pagination-id="table_authaccess"></dir-pagination-controls>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
angular.module('portainer').component('porImageRegistry', {
|
||||
templateUrl: 'app/directives/imageRegistry/porImageRegistry.html',
|
||||
controller: 'porImageRegistryController',
|
||||
bindings: {
|
||||
'image': '=',
|
||||
'registry': '=',
|
||||
'autoComplete': '<'
|
||||
}
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
angular.module('ui', []);
|
||||
@@ -1,105 +0,0 @@
|
||||
<div class="datatable">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle">
|
||||
<i class="fa" 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...">
|
||||
</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-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'name' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" 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-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'cpu' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" 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-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'memory' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" 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-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ip' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" 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-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'version' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" 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-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'status' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" 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('ui').component('nodesSsDatatable', {
|
||||
templateUrl: 'app/directives/ui/datatables/nodes-ss-datatable/nodesSSDatatable.html',
|
||||
controller: 'GenericDatatableController',
|
||||
bindings: {
|
||||
title: '@',
|
||||
titleIcon: '@',
|
||||
dataset: '<',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<'
|
||||
}
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
angular.module('ui').component('tasksDatatable', {
|
||||
templateUrl: 'app/directives/ui/datatables/tasks-datatable/tasksDatatable.html',
|
||||
controller: 'GenericDatatableController',
|
||||
bindings: {
|
||||
title: '@',
|
||||
titleIcon: '@',
|
||||
dataset: '<',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
nodes: '<',
|
||||
showTextFilter: '<',
|
||||
showSlotColumn: '<'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
angular.module('portainer.docker', ['portainer.app'])
|
||||
.config(['$stateRegistryProvider', function ($stateRegistryProvider) {
|
||||
'use strict';
|
||||
|
||||
var docker = {
|
||||
name: 'docker',
|
||||
parent: 'root',
|
||||
abstract: true
|
||||
};
|
||||
|
||||
var configs = {
|
||||
name: 'docker.configs',
|
||||
url: '/configs',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/configs/configs.html',
|
||||
controller: 'ConfigsController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var config = {
|
||||
name: 'docker.configs.config',
|
||||
url: '/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/configs/edit/config.html',
|
||||
controller: 'ConfigController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var configCreation = {
|
||||
name: 'docker.configs.new',
|
||||
url: '/new',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/configs/create/createconfig.html',
|
||||
controller: 'CreateConfigController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var containers = {
|
||||
name: 'docker.containers',
|
||||
url: '/containers',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/containers/containers.html',
|
||||
controller: 'ContainersController'
|
||||
}
|
||||
},
|
||||
params: {
|
||||
selectedContainers: []
|
||||
}
|
||||
};
|
||||
|
||||
var container = {
|
||||
name: 'docker.containers.container',
|
||||
url: '/:id?nodeName',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/containers/edit/container.html',
|
||||
controller: 'ContainerController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var containerConsole = {
|
||||
name: 'docker.containers.container.console',
|
||||
url: '/console',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/containers/console/containerconsole.html',
|
||||
controller: 'ContainerConsoleController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var containerCreation = {
|
||||
name: 'docker.containers.new',
|
||||
url: '/new?nodeName&from',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/containers/create/createcontainer.html',
|
||||
controller: 'CreateContainerController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var containerInspect = {
|
||||
name: 'docker.containers.container.inspect',
|
||||
url: '/inspect',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/containers/inspect/containerinspect.html',
|
||||
controller: 'ContainerInspectController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var containerLogs = {
|
||||
name: 'docker.containers.container.logs',
|
||||
url: '/logs',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/containers/logs/containerlogs.html',
|
||||
controller: 'ContainerLogsController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var containerStats = {
|
||||
name: 'docker.containers.container.stats',
|
||||
url: '/stats',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/containers/stats/containerstats.html',
|
||||
controller: 'ContainerStatsController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var dashboard = {
|
||||
name: 'docker.dashboard',
|
||||
url: '/dashboard',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/dashboard/dashboard.html',
|
||||
controller: 'DashboardController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var engine = {
|
||||
name: 'docker.engine',
|
||||
url: '/engine',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/engine/engine.html',
|
||||
controller: 'EngineController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var events = {
|
||||
name: 'docker.events',
|
||||
url: '/events',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/events/events.html',
|
||||
controller: 'EventsController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var images = {
|
||||
name: 'docker.images',
|
||||
url: '/images',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/images/images.html',
|
||||
controller: 'ImagesController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var image = {
|
||||
name: 'docker.images.image',
|
||||
url: '/:id?nodeName',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/images/edit/image.html',
|
||||
controller: 'ImageController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var imageBuild = {
|
||||
name: 'docker.images.build',
|
||||
url: '/build',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/images/build/buildimage.html',
|
||||
controller: 'BuildImageController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var networks = {
|
||||
name: 'docker.networks',
|
||||
url: '/networks',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/networks/networks.html',
|
||||
controller: 'NetworksController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var network = {
|
||||
name: 'docker.networks.network',
|
||||
url: '/:id?nodeName',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/networks/edit/network.html',
|
||||
controller: 'NetworkController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var networkCreation = {
|
||||
name: 'docker.networks.new',
|
||||
url: '/new',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/networks/create/createnetwork.html',
|
||||
controller: 'CreateNetworkController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var nodes = {
|
||||
name: 'docker.nodes',
|
||||
url: '/nodes',
|
||||
abstract: true
|
||||
};
|
||||
|
||||
var node = {
|
||||
name: 'docker.nodes.node',
|
||||
url: '/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/nodes/edit/node.html',
|
||||
controller: 'NodeController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var secrets = {
|
||||
name: 'docker.secrets',
|
||||
url: '/secrets',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/secrets/secrets.html',
|
||||
controller: 'SecretsController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var secret = {
|
||||
name: 'docker.secrets.secret',
|
||||
url: '/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/secrets/edit/secret.html',
|
||||
controller: 'SecretController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var secretCreation = {
|
||||
name: 'docker.secrets.new',
|
||||
url: '/new',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/secrets/create/createsecret.html',
|
||||
controller: 'CreateSecretController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var services = {
|
||||
name: 'docker.services',
|
||||
url: '/services',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/services/services.html',
|
||||
controller: 'ServicesController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var service = {
|
||||
name: 'docker.services.service',
|
||||
url: '/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/services/edit/service.html',
|
||||
controller: 'ServiceController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var serviceCreation = {
|
||||
name: 'docker.services.new',
|
||||
url: '/new',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/services/create/createservice.html',
|
||||
controller: 'CreateServiceController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var serviceLogs = {
|
||||
name: 'docker.services.service.logs',
|
||||
url: '/logs',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/services/logs/servicelogs.html',
|
||||
controller: 'ServiceLogsController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var stacks = {
|
||||
name: 'docker.stacks',
|
||||
url: '/stacks',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/stacks/stacks.html',
|
||||
controller: 'StacksController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var stack = {
|
||||
name: 'docker.stacks.stack',
|
||||
url: '/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/stacks/edit/stack.html',
|
||||
controller: 'StackController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var stackCreation = {
|
||||
name: 'docker.stacks.new',
|
||||
url: '/new',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/stacks/create/createstack.html',
|
||||
controller: 'CreateStackController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var swarm = {
|
||||
name: 'docker.swarm',
|
||||
url: '/swarm',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/swarm/swarm.html',
|
||||
controller: 'SwarmController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var swarmVisualizer = {
|
||||
name: 'docker.swarm.visualizer',
|
||||
url: '/visualizer',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/swarm/visualizer/swarmvisualizer.html',
|
||||
controller: 'SwarmVisualizerController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var tasks = {
|
||||
name: 'docker.tasks',
|
||||
url: '/tasks',
|
||||
abstract: true
|
||||
};
|
||||
|
||||
var task = {
|
||||
name: 'docker.tasks.task',
|
||||
url: '/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/tasks/edit/task.html',
|
||||
controller: 'TaskController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var taskLogs = {
|
||||
name: 'docker.tasks.task.logs',
|
||||
url: '/logs',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/tasks/logs/tasklogs.html',
|
||||
controller: 'TaskLogsController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var templates = {
|
||||
name: 'docker.templates',
|
||||
url: '/templates',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/templates/templates.html',
|
||||
controller: 'TemplatesController'
|
||||
}
|
||||
},
|
||||
params: {
|
||||
key: 'containers',
|
||||
hide_descriptions: false
|
||||
}
|
||||
};
|
||||
|
||||
var templatesLinuxServer = {
|
||||
name: 'docker.templates.linuxserver',
|
||||
url: '/linuxserver',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/templates/templates.html',
|
||||
controller: 'TemplatesController'
|
||||
}
|
||||
},
|
||||
params: {
|
||||
key: 'linuxserver.io',
|
||||
hide_descriptions: true
|
||||
}
|
||||
};
|
||||
|
||||
var volumes = {
|
||||
name: 'docker.volumes',
|
||||
url: '/volumes',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/volumes/volumes.html',
|
||||
controller: 'VolumesController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var volume = {
|
||||
name: 'docker.volumes.volume',
|
||||
url: '/:id?nodeName',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/volumes/edit/volume.html',
|
||||
controller: 'VolumeController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var volumeCreation = {
|
||||
name: 'docker.volumes.new',
|
||||
url: '/new',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/docker/views/volumes/create/createvolume.html',
|
||||
controller: 'CreateVolumeController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$stateRegistryProvider.register(configs);
|
||||
$stateRegistryProvider.register(config);
|
||||
$stateRegistryProvider.register(configCreation);
|
||||
$stateRegistryProvider.register(containers);
|
||||
$stateRegistryProvider.register(container);
|
||||
$stateRegistryProvider.register(containerConsole);
|
||||
$stateRegistryProvider.register(containerCreation);
|
||||
$stateRegistryProvider.register(containerInspect);
|
||||
$stateRegistryProvider.register(containerLogs);
|
||||
$stateRegistryProvider.register(containerStats);
|
||||
$stateRegistryProvider.register(docker);
|
||||
$stateRegistryProvider.register(dashboard);
|
||||
$stateRegistryProvider.register(engine);
|
||||
$stateRegistryProvider.register(events);
|
||||
$stateRegistryProvider.register(images);
|
||||
$stateRegistryProvider.register(image);
|
||||
$stateRegistryProvider.register(imageBuild);
|
||||
$stateRegistryProvider.register(networks);
|
||||
$stateRegistryProvider.register(network);
|
||||
$stateRegistryProvider.register(networkCreation);
|
||||
$stateRegistryProvider.register(nodes);
|
||||
$stateRegistryProvider.register(node);
|
||||
$stateRegistryProvider.register(secrets);
|
||||
$stateRegistryProvider.register(secret);
|
||||
$stateRegistryProvider.register(secretCreation);
|
||||
$stateRegistryProvider.register(services);
|
||||
$stateRegistryProvider.register(service);
|
||||
$stateRegistryProvider.register(serviceCreation);
|
||||
$stateRegistryProvider.register(serviceLogs);
|
||||
$stateRegistryProvider.register(stacks);
|
||||
$stateRegistryProvider.register(stack);
|
||||
$stateRegistryProvider.register(stackCreation);
|
||||
$stateRegistryProvider.register(swarm);
|
||||
$stateRegistryProvider.register(swarmVisualizer);
|
||||
$stateRegistryProvider.register(tasks);
|
||||
$stateRegistryProvider.register(task);
|
||||
$stateRegistryProvider.register(taskLogs);
|
||||
$stateRegistryProvider.register(templates);
|
||||
$stateRegistryProvider.register(templatesLinuxServer);
|
||||
$stateRegistryProvider.register(volumes);
|
||||
$stateRegistryProvider.register(volume);
|
||||
$stateRegistryProvider.register(volumeCreation);
|
||||
}]);
|
||||
@@ -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>
|
||||
+16
@@ -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');
|
||||
});
|
||||
};
|
||||
|
||||
}]);
|
||||
+10
-10
@@ -14,15 +14,15 @@
|
||||
<div class="actionBar">
|
||||
<button type="button" class="btn btn-sm btn-danger"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
|
||||
<i class="fa fa-trash space-right" aria-hidden="true"></i>Remove
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="actions.create.config">
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.configs.new">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add config
|
||||
</button>
|
||||
</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...">
|
||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
@@ -35,22 +35,22 @@
|
||||
</span>
|
||||
<a ng-click="$ctrl.changeOrderBy('Name')">
|
||||
Name
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
|
||||
<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('CreatedAt')">
|
||||
Creation Date
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'CreatedAt' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'CreatedAt' && $ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'CreatedAt' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'CreatedAt' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="$ctrl.showOwnershipColumn">
|
||||
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
|
||||
Ownership
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && $ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -62,7 +62,7 @@
|
||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||
<label for="select_{{ $index }}"></label>
|
||||
</span>
|
||||
<a ui-sref="config({id: item.Id})">{{ item.Name }}</a>
|
||||
<a ui-sref="docker.configs.config({id: item.Id})">{{ item.Name }}</a>
|
||||
</td>
|
||||
<td>{{ item.CreatedAt | getisodate }}</td>
|
||||
<td ng-if="$ctrl.showOwnershipColumn">
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
angular.module('ui').component('configsDatatable', {
|
||||
templateUrl: 'app/directives/ui/datatables/configs-datatable/configsDatatable.html',
|
||||
angular.module('portainer.docker').component('configsDatatable', {
|
||||
templateUrl: 'app/docker/components/datatables/configs-datatable/configsDatatable.html',
|
||||
controller: 'GenericDatatableController',
|
||||
bindings: {
|
||||
title: '@',
|
||||
+2
-2
@@ -38,13 +38,13 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr dir-paginate="(key, value) in $ctrl.dataset | itemsPerPage: $ctrl.state.paginatedItemLimit" ng-class="{active: item.Checked}">
|
||||
<td><a ui-sref="network({id: value.NetworkID})">{{ key }}</a></td>
|
||||
<td><a ui-sref="docker.networks.network({ id: value.NetworkID, nodeName: $ctrl.nodeName })">{{ key }}</a></td>
|
||||
<td>{{ value.IPAddress || '-' }}</td>
|
||||
<td>{{ value.Gateway || '-' }}</td>
|
||||
<td>{{ value.MacAddress || '-' }}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-xs btn-danger" ng-disabled="$ctrl.leaveNetworkActionInProgress" button-spinner="$ctrl.leaveNetworkActionInProgress" ng-click="$ctrl.leaveNetworkAction($ctrl.container, value.NetworkID)">
|
||||
<span ng-hide="$ctrl.leaveNetworkActionInProgress"><i class="fa fa-trash space-right" aria-hidden="true"></i> Leave network</span>
|
||||
<span ng-hide="$ctrl.leaveNetworkActionInProgress"><i class="fa fa-trash-alt space-right" aria-hidden="true"></i> Leave network</span>
|
||||
<span ng-show="$ctrl.leaveNetworkActionInProgress">Leaving network...</span>
|
||||
</button>
|
||||
</td>
|
||||
+4
-3
@@ -1,5 +1,5 @@
|
||||
angular.module('ui').component('containerNetworksDatatable', {
|
||||
templateUrl: 'app/directives/ui/datatables/container-networks-datatable/containerNetworksDatatable.html',
|
||||
angular.module('portainer.docker').component('containerNetworksDatatable', {
|
||||
templateUrl: 'app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html',
|
||||
controller: 'GenericDatatableController',
|
||||
bindings: {
|
||||
title: '@',
|
||||
@@ -11,6 +11,7 @@ angular.module('ui').component('containerNetworksDatatable', {
|
||||
joinNetworkAction: '<',
|
||||
joinNetworkActionInProgress: '<',
|
||||
leaveNetworkActionInProgress: '<',
|
||||
leaveNetworkAction: '<'
|
||||
leaveNetworkAction: '<',
|
||||
nodeName: '<'
|
||||
}
|
||||
});
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
</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...">
|
||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
angular.module('ui').component('containerProcessesDatatable', {
|
||||
templateUrl: 'app/directives/ui/datatables/container-processes-datatable/containerProcessesDatatable.html',
|
||||
angular.module('portainer.docker').component('containerProcessesDatatable', {
|
||||
templateUrl: 'app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html',
|
||||
controller: 'GenericDatatableController',
|
||||
bindings: {
|
||||
title: '@',
|
||||
+33
-34
@@ -67,7 +67,7 @@
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ng-click="$ctrl.restartAction($ctrl.state.selectedItems)"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0">
|
||||
<i class="fa fa-refresh space-right" aria-hidden="true"></i>Restart
|
||||
<i class="fa fa-sync space-right" aria-hidden="true"></i>Restart
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ng-click="$ctrl.pauseAction($ctrl.state.selectedItems)"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0 || $ctrl.state.noRunningItemsSelected">
|
||||
@@ -79,16 +79,16 @@
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
|
||||
<i class="fa fa-trash space-right" aria-hidden="true"></i>Remove
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="actions.create.container">
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.containers.new">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add container
|
||||
</button>
|
||||
</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...">
|
||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-filters">
|
||||
@@ -101,15 +101,15 @@
|
||||
</span>
|
||||
<a ng-click="$ctrl.changeOrderBy('Names')">
|
||||
Name
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Names' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Names' && $ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Names' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Names' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open">
|
||||
<a ng-click="$ctrl.changeOrderBy('Status')">
|
||||
State
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && $ctrl.state.reverseOrder"></i>
|
||||
<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>
|
||||
<div>
|
||||
<span uib-dropdown-toggle class="table-filter" ng-if="!$ctrl.filters.state.enabled">Filter <i class="fa fa-filter" aria-hidden="true"></i></span>
|
||||
@@ -138,43 +138,43 @@
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('StackName')">
|
||||
Stack
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'StackName' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'StackName' && $ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'StackName' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'StackName' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('Image')">
|
||||
Image
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Image' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Image' && $ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Image' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Image' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('IP')">
|
||||
IP Address
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IP' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IP' && $ctrl.state.reverseOrder"></i>
|
||||
<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 ng-if="$ctrl.swarmContainers">
|
||||
<a ng-click="$ctrl.changeOrderBy('Host')">
|
||||
Host IP
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Host' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Host' && $ctrl.state.reverseOrder"></i>
|
||||
<th ng-if="$ctrl.showHostColumn">
|
||||
<a ng-click="$ctrl.changeOrderBy('NodeName')">
|
||||
Host
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('Ports')">
|
||||
Published Ports
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Ports' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Ports' && $ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Ports' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Ports' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="$ctrl.showOwnershipColumn">
|
||||
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
|
||||
Ownership
|
||||
<i class="fa fa-sort-alpha-asc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && $ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -186,8 +186,7 @@
|
||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||
<label for="select_{{ $index }}"></label>
|
||||
</span>
|
||||
<a ui-sref="container({ id: item.Id })" ng-if="!$ctrl.swarmContainers">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
|
||||
<a ui-sref="container({ id: item.Id })" ng-if="$ctrl.swarmContainers">{{ item | swarmcontainername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
|
||||
<a ui-sref="docker.containers.container({ id: item.Id, nodeName: item.NodeName })">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ item.Status }}</span>
|
||||
@@ -195,19 +194,19 @@
|
||||
</td>
|
||||
<td ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect">
|
||||
<div class="btn-group btn-group-xs" role="group" aria-label="..." style="display:inline-flex;">
|
||||
<a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="stats({id: item.Id})"><i class="fa fa-area-chart space-right" aria-hidden="true"></i></a>
|
||||
<a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="containerlogs({id: item.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i></a>
|
||||
<a ng-if="$ctrl.settings.showQuickActionConsole" style="margin: 0 2.5px;" ui-sref="console({id: item.Id})"><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="inspect({id: item.Id})"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a>
|
||||
<a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="docker.containers.container.stats({id: item.Id, nodeName: item.NodeName})" title="Stats"><i class="fa fa-chart-area space-right" aria-hidden="true"></i></a>
|
||||
<a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="docker.containers.container.logs({id: item.Id, nodeName: item.NodeName})" title="Logs"><i class="fa fa-file-alt space-right" aria-hidden="true"></i></a>
|
||||
<a ng-if="$ctrl.settings.showQuickActionConsole" style="margin: 0 2.5px;" ui-sref="docker.containers.container.console({id: item.Id, nodeName: item.NodeName})" title="Console"><i class="fa fa-terminal space-right" aria-hidden="true"></i></a>
|
||||
<a ng-if="$ctrl.settings.showQuickActionInspect" style="margin: 0 2.5px;" ui-sref="docker.containers.container.inspect({id: item.Id, nodeName: item.NodeName})" title="Inspect"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ item.StackName ? item.StackName : '-' }}</td>
|
||||
<td><a ui-sref="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 ng-if="$ctrl.swarmContainers">{{ item.hostIP }}</td>
|
||||
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
|
||||
<td>
|
||||
<a ng-if="item.Ports.length > 0" ng-repeat="p in item.Ports" class="image-tag" ng-href="http://{{ $ctrl.publicUrl || p.host }}:{{p.public}}" target="_blank">
|
||||
<i class="fa fa-external-link" aria-hidden="true"></i> {{ p.public }}:{{ p.private }}
|
||||
<i class="fa fa-external-link-alt" aria-hidden="true"></i> {{ p.public }}:{{ p.private }}
|
||||
</a>
|
||||
<span ng-if="item.Ports.length == 0" >-</span>
|
||||
</td>
|
||||
@@ -219,10 +218,10 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.dataset">
|
||||
<td colspan="8" class="text-center text-muted">Loading...</td>
|
||||
<td colspan="9" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
||||
<td colspan="8" class="text-center text-muted">No container available.</td>
|
||||
<td colspan="9" class="text-center text-muted">No container available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user