mirror of https://github.com/portainer/portainer
commit
f42733b74c
|
@ -44,7 +44,3 @@ steps:
|
|||
candidate: '${{build_image}}'
|
||||
tag: '${{CF_BRANCH}}'
|
||||
registry: dockerhub
|
||||
when:
|
||||
branch:
|
||||
only:
|
||||
- develop
|
|
@ -0,0 +1,28 @@
|
|||
package bolt
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
func (m *Migrator) updateEndpointsToVersion11() error {
|
||||
legacyEndpoints, err := m.EndpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range legacyEndpoints {
|
||||
if endpoint.Type == portainer.AgentOnDockerEnvironment {
|
||||
endpoint.TLSConfig.TLS = true
|
||||
endpoint.TLSConfig.TLSSkipVerify = true
|
||||
} else {
|
||||
if endpoint.TLSConfig.TLSSkipVerify && !endpoint.TLSConfig.TLS {
|
||||
endpoint.TLSConfig.TLSSkipVerify = false
|
||||
}
|
||||
}
|
||||
|
||||
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -112,6 +112,14 @@ func (m *Migrator) Migrate() error {
|
|||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1906
|
||||
if m.CurrentDBVersion < 11 {
|
||||
err := m.updateEndpointsToVersion11()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -33,11 +33,11 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
|||
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(),
|
||||
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(),
|
||||
EndpointURL: kingpin.Flag("host", "Endpoint URL").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(),
|
||||
TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).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(),
|
||||
|
@ -69,11 +69,11 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
|||
// ValidateFlags validates the values of the flags.
|
||||
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||
|
||||
if *flags.Endpoint != "" && *flags.ExternalEndpoints != "" {
|
||||
if *flags.EndpointURL != "" && *flags.ExternalEndpoints != "" {
|
||||
return errEndpointExcludeExternal
|
||||
}
|
||||
|
||||
err := validateEndpoint(*flags.Endpoint)
|
||||
err := validateEndpointURL(*flags.EndpointURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -99,14 +99,14 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateEndpoint(endpoint string) error {
|
||||
if endpoint != "" {
|
||||
if !strings.HasPrefix(endpoint, "unix://") && !strings.HasPrefix(endpoint, "tcp://") {
|
||||
func validateEndpointURL(endpointURL string) error {
|
||||
if endpointURL != "" {
|
||||
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") {
|
||||
return errInvalidEndpointProtocol
|
||||
}
|
||||
|
||||
if strings.HasPrefix(endpoint, "unix://") {
|
||||
socketPath := strings.TrimPrefix(endpoint, "unix://")
|
||||
if strings.HasPrefix(endpointURL, "unix://") {
|
||||
socketPath := strings.TrimPrefix(endpointURL, "unix://")
|
||||
if _, err := os.Stat(socketPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errSocketNotFound
|
||||
|
|
|
@ -8,7 +8,7 @@ const (
|
|||
defaultAssetsDirectory = "./"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLSVerify = "false"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
|
|
|
@ -6,7 +6,7 @@ const (
|
|||
defaultAssetsDirectory = "./"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLSVerify = "false"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package main // import "github.com/portainer/portainer"
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt"
|
||||
"github.com/portainer/portainer/cli"
|
||||
|
@ -62,8 +64,8 @@ func initStore(dataStorePath string) *bolt.Store {
|
|||
return store
|
||||
}
|
||||
|
||||
func initStackManager(assetsPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) {
|
||||
return exec.NewStackManager(assetsPath, signatureService, fileService)
|
||||
func initStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) {
|
||||
return exec.NewStackManager(assetsPath, dataStorePath, signatureService, fileService)
|
||||
}
|
||||
|
||||
func initJWTService(authenticationEnabled bool) portainer.JWTService {
|
||||
|
@ -207,6 +209,93 @@ func initKeyPair(fileService portainer.FileService, signatureService portainer.D
|
|||
return generateAndStoreKeyPair(fileService, signatureService)
|
||||
}
|
||||
|
||||
func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService) error {
|
||||
tlsConfiguration := portainer.TLSConfiguration{
|
||||
TLS: *flags.TLS,
|
||||
TLSSkipVerify: *flags.TLSSkipVerify,
|
||||
}
|
||||
|
||||
if *flags.TLS {
|
||||
tlsConfiguration.TLSCACertPath = *flags.TLSCacert
|
||||
tlsConfiguration.TLSCertPath = *flags.TLSCert
|
||||
tlsConfiguration.TLSKeyPath = *flags.TLSKey
|
||||
} else if !*flags.TLS && *flags.TLSSkipVerify {
|
||||
tlsConfiguration.TLS = true
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: "primary",
|
||||
URL: *flags.EndpointURL,
|
||||
GroupID: portainer.EndpointGroupID(1),
|
||||
Type: portainer.DockerEnvironment,
|
||||
TLSConfig: tlsConfiguration,
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
}
|
||||
|
||||
if strings.HasPrefix(endpoint.URL, "tcp://") {
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(tlsConfiguration.TLSCACertPath, tlsConfiguration.TLSCertPath, tlsConfiguration.TLSKeyPath, tlsConfiguration.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentOnDockerEnvironment, err := client.ExecutePingOperation(endpoint.URL, tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if agentOnDockerEnvironment {
|
||||
endpoint.Type = portainer.AgentOnDockerEnvironment
|
||||
}
|
||||
}
|
||||
|
||||
return endpointService.CreateEndpoint(endpoint)
|
||||
}
|
||||
|
||||
func createUnsecuredEndpoint(endpointURL string, endpointService portainer.EndpointService) error {
|
||||
if strings.HasPrefix(endpointURL, "tcp://") {
|
||||
_, err := client.ExecutePingOperation(endpointURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: "primary",
|
||||
URL: endpointURL,
|
||||
GroupID: portainer.EndpointGroupID(1),
|
||||
Type: portainer.DockerEnvironment,
|
||||
TLSConfig: portainer.TLSConfiguration{},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
}
|
||||
|
||||
return endpointService.CreateEndpoint(endpoint)
|
||||
}
|
||||
|
||||
func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService) error {
|
||||
if *flags.EndpointURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
endpoints, err := endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(endpoints) > 0 {
|
||||
log.Println("Instance already has defined endpoints. Skipping the endpoint defined via CLI.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if *flags.TLS || *flags.TLSSkipVerify {
|
||||
return createTLSSecuredEndpoint(flags, endpointService)
|
||||
}
|
||||
return createUnsecuredEndpoint(*flags.EndpointURL, endpointService)
|
||||
}
|
||||
|
||||
func main() {
|
||||
flags := initCLI()
|
||||
|
||||
|
@ -232,7 +321,7 @@ func main() {
|
|||
log.Fatal(err)
|
||||
}
|
||||
|
||||
stackManager, err := initStackManager(*flags.Assets, digitalSignatureService, fileService)
|
||||
stackManager, err := initStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
@ -249,45 +338,10 @@ func main() {
|
|||
|
||||
applicationStatus := initStatus(authorizeEndpointMgmt, flags)
|
||||
|
||||
if *flags.Endpoint != "" {
|
||||
endpoints, err := store.EndpointService.Endpoints()
|
||||
err = initEndpoint(flags, store.EndpointService)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(endpoints) == 0 {
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: "primary",
|
||||
URL: *flags.Endpoint,
|
||||
Type: portainer.DockerEnvironment,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: *flags.TLSVerify,
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
log.Println("Instance already has defined endpoints. Skipping the endpoint defined via CLI.")
|
||||
}
|
||||
}
|
||||
|
||||
adminPasswordHash := ""
|
||||
if *flags.AdminPasswordFile != "" {
|
||||
|
|
|
@ -4,11 +4,11 @@ import (
|
|||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
func CreateTLSConfig(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) {
|
||||
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
// loaded from memory.
|
||||
func CreateTLSConfigurationFromBytes(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) {
|
||||
config := &tls.Config{}
|
||||
config.InsecureSkipVerify = skipServerVerification
|
||||
|
||||
|
@ -29,32 +29,31 @@ func CreateTLSConfig(caCert, cert, key []byte, skipClientVerification, skipServe
|
|||
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{}
|
||||
// CreateTLSConfigurationFromDisk initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
// loaded from disk.
|
||||
func CreateTLSConfigurationFromDisk(caCertPath, certPath, keyPath string, skipServerVerification bool) (*tls.Config, error) {
|
||||
config := &tls.Config{}
|
||||
config.InsecureSkipVerify = skipServerVerification
|
||||
|
||||
if config.TLS && config.TLSCertPath != "" && config.TLSKeyPath != "" {
|
||||
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
|
||||
if certPath != "" && keyPath != "" {
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
TLSConfig.Certificates = []tls.Certificate{cert}
|
||||
config.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
if config.TLS && !config.TLSSkipVerify {
|
||||
caCert, err := ioutil.ReadFile(config.TLSCACertPath)
|
||||
if !skipServerVerification && caCertPath != "" {
|
||||
caCert, err := ioutil.ReadFile(caCertPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
|
||||
TLSConfig.RootCAs = caCertPool
|
||||
config.RootCAs = caCertPool
|
||||
}
|
||||
|
||||
TLSConfig.InsecureSkipVerify = config.TLSSkipVerify
|
||||
|
||||
return TLSConfig, nil
|
||||
return config, nil
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package exec
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
|
@ -13,28 +14,22 @@ import (
|
|||
// StackManager represents a service for managing stacks.
|
||||
type StackManager struct {
|
||||
binaryPath string
|
||||
dataPath 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.
|
||||
// It also updates the configuration of the Docker CLI binary.
|
||||
func NewStackManager(binaryPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) {
|
||||
func NewStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) {
|
||||
manager := &StackManager{
|
||||
binaryPath: binaryPath,
|
||||
dataPath: dataPath,
|
||||
signatureService: signatureService,
|
||||
fileService: fileService,
|
||||
}
|
||||
|
||||
err := manager.updateDockerCLIConfiguration(binaryPath)
|
||||
err := manager.updateDockerCLIConfiguration(dataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -44,7 +39,7 @@ func NewStackManager(binaryPath string, signatureService portainer.DigitalSignat
|
|||
|
||||
// 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) {
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
|
||||
for _, registry := range registries {
|
||||
if registry.Authentication {
|
||||
registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL)
|
||||
|
@ -60,7 +55,7 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []
|
|||
|
||||
// Logout executes the docker logout command.
|
||||
func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error {
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
|
||||
args = append(args, "logout")
|
||||
return runCommandAndCaptureStdErr(command, args, nil, "")
|
||||
}
|
||||
|
@ -68,7 +63,7 @@ func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error {
|
|||
// Deploy executes the docker stack deploy command.
|
||||
func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error {
|
||||
stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
|
||||
|
||||
if prune {
|
||||
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
|
||||
|
@ -87,7 +82,7 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint
|
|||
|
||||
// Remove executes the docker stack rm command.
|
||||
func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
|
||||
args = append(args, "stack", "rm", stack.Name)
|
||||
return runCommandAndCaptureStdErr(command, args, nil, "")
|
||||
}
|
||||
|
@ -111,7 +106,7 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor
|
|||
return nil
|
||||
}
|
||||
|
||||
func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint) (string, []string) {
|
||||
func prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portainer.Endpoint) (string, []string) {
|
||||
// Assume Linux as a default
|
||||
command := path.Join(binaryPath, "docker")
|
||||
|
||||
|
@ -120,12 +115,10 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
|
|||
}
|
||||
|
||||
args := make([]string, 0)
|
||||
args = append(args, "--config", binaryPath)
|
||||
args = append(args, "--config", dataPath)
|
||||
args = append(args, "-H", endpoint.URL)
|
||||
|
||||
if !endpoint.TLSConfig.TLS && endpoint.TLSConfig.TLSSkipVerify {
|
||||
args = append(args, "--tls")
|
||||
} else if endpoint.TLSConfig.TLS {
|
||||
if endpoint.TLSConfig.TLS {
|
||||
args = append(args, "--tls")
|
||||
|
||||
if !endpoint.TLSConfig.TLSSkipVerify {
|
||||
|
@ -140,21 +133,46 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
|
|||
return command, args
|
||||
}
|
||||
|
||||
func (manager *StackManager) updateDockerCLIConfiguration(binaryPath string) error {
|
||||
config := dockerCLIConfiguration{}
|
||||
config.HTTPHeaders.ManagerOperationHeader = "1"
|
||||
func (manager *StackManager) updateDockerCLIConfiguration(dataPath string) error {
|
||||
configFilePath := path.Join(dataPath, "config.json")
|
||||
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 config["HttpHeaders"] == nil {
|
||||
config["HttpHeaders"] = make(map[string]interface{})
|
||||
}
|
||||
headersObject := config["HttpHeaders"].(map[string]interface{})
|
||||
headersObject["X-PortainerAgent-ManagerOperation"] = "1"
|
||||
headersObject["X-PortainerAgent-Signature"] = signature
|
||||
headersObject["X-PortainerAgent-PublicKey"] = manager.signatureService.EncodedPublicKey()
|
||||
|
||||
err = manager.fileService.WriteJSONToFile(configFilePath, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *StackManager) retrieveConfigurationFromDisk(path string) (map[string]interface{}, error) {
|
||||
var config map[string]interface{}
|
||||
|
||||
raw, err := manager.fileService.GetFileContent(path)
|
||||
if err != nil {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(raw), &config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
|
|
@ -7,39 +7,8 @@ import (
|
|||
"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) {
|
||||
|
|
|
@ -121,7 +121,7 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
|
|||
}
|
||||
|
||||
func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
|
||||
tlsConfig, err := crypto.CreateTLSConfig(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification)
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -472,7 +472,7 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
|
|||
}
|
||||
} else {
|
||||
endpoint.TLSConfig.TLS = false
|
||||
endpoint.TLSConfig.TLSSkipVerify = true
|
||||
endpoint.TLSConfig.TLSSkipVerify = false
|
||||
endpoint.TLSConfig.TLSCACertPath = ""
|
||||
endpoint.TLSConfig.TLSCertPath = ""
|
||||
endpoint.TLSConfig.TLSKeyPath = ""
|
||||
|
|
|
@ -94,6 +94,8 @@ func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *h
|
|||
}
|
||||
|
||||
func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
|
||||
r.Header.Del("Origin")
|
||||
|
||||
if params.nodeName != "" {
|
||||
return handler.proxyWebsocketRequest(w, r, params)
|
||||
}
|
||||
|
@ -135,7 +137,6 @@ func (handler *WebSocketHandler) proxyWebsocketRequest(w http.ResponseWriter, r
|
|||
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
|
||||
}
|
||||
|
||||
r.Header.Del("Origin")
|
||||
proxy.ServeHTTP(w, r)
|
||||
|
||||
return nil
|
||||
|
@ -187,7 +188,7 @@ func createDial(endpoint *portainer.Endpoint) (net.Conn, error) {
|
|||
}
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portaine
|
|||
u.Scheme = "https"
|
||||
|
||||
proxy := factory.createDockerReverseProxy(u, enableSignature)
|
||||
config, err := crypto.CreateTLSConfiguration(tlsConfig)
|
||||
config, err := crypto.CreateTLSConfigurationFromDisk(tlsConfig.TLSCACertPath, tlsConfig.TLSCertPath, tlsConfig.TLSKeyPath, tlsConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht
|
|||
}
|
||||
|
||||
if endpointURL.Scheme == "tcp" {
|
||||
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
|
||||
if endpoint.TLSConfig.TLS {
|
||||
proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
|
@ -48,8 +49,10 @@ func getResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
|
|||
if responseObject["message"] != nil {
|
||||
return nil, portainer.Error(responseObject["message"].(string))
|
||||
}
|
||||
log.Printf("Response: %+v\n", responseObject)
|
||||
return nil, ErrInvalidResponseContent
|
||||
default:
|
||||
log.Printf("Response: %+v\n", responseObject)
|
||||
return nil, ErrInvalidResponseContent
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,7 +55,7 @@ func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearc
|
|||
func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
|
||||
|
||||
if settings.TLSConfig.TLS || settings.StartTLS {
|
||||
config, err := crypto.CreateTLSConfiguration(&settings.TLSConfig)
|
||||
config, err := crypto.CreateTLSConfigurationFromDisk(settings.TLSConfig.TLSCACertPath, settings.TLSConfig.TLSCertPath, settings.TLSConfig.TLSKeyPath, settings.TLSConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -16,14 +16,14 @@ type (
|
|||
AdminPasswordFile *string
|
||||
Assets *string
|
||||
Data *string
|
||||
Endpoint *string
|
||||
EndpointURL *string
|
||||
ExternalEndpoints *string
|
||||
Labels *[]Pair
|
||||
Logo *string
|
||||
NoAuth *bool
|
||||
NoAnalytics *bool
|
||||
Templates *string
|
||||
TLSVerify *bool
|
||||
TLS *bool
|
||||
TLSSkipVerify *bool
|
||||
TLSCacert *string
|
||||
TLSCert *string
|
||||
|
@ -443,9 +443,9 @@ type (
|
|||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API.
|
||||
APIVersion = "1.17.0"
|
||||
APIVersion = "1.17.1"
|
||||
// DBVersion is the version number of the Portainer database.
|
||||
DBVersion = 10
|
||||
DBVersion = 11
|
||||
// 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
|
||||
|
|
106
api/swagger.yaml
106
api/swagger.yaml
|
@ -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.17.0"
|
||||
version: "1.17.1"
|
||||
title: "Portainer API"
|
||||
contact:
|
||||
email: "info@portainer.io"
|
||||
|
@ -228,12 +228,45 @@ paths:
|
|||
produces:
|
||||
- "application/json"
|
||||
parameters:
|
||||
- in: "body"
|
||||
name: "body"
|
||||
description: "Endpoint details"
|
||||
- name: "Name"
|
||||
in: "formData"
|
||||
type: "string"
|
||||
description: "Name that will be used to identify this endpoint (example: my-endpoint)"
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/EndpointCreateRequest"
|
||||
- name: "URL"
|
||||
in: "formData"
|
||||
type: "string"
|
||||
description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375)"
|
||||
required: true
|
||||
- name: "PublicURL"
|
||||
in: "formData"
|
||||
type: "string"
|
||||
description: "URL or IP address where exposed containers will be reachable.\
|
||||
\ Defaults to URL if not specified (example: docker.mydomain.tld:2375)"
|
||||
- name: "GroupID"
|
||||
in: "formData"
|
||||
type: "string"
|
||||
description: "Endpoint group identifier. If not specified will default to 1 (unassigned)."
|
||||
- name: "TLS"
|
||||
in: "formData"
|
||||
type: "string"
|
||||
description: "Require TLS to connect against this endpoint (example: true)"
|
||||
- name: "TLSSkipVerify"
|
||||
in: "formData"
|
||||
type: "string"
|
||||
description: "Skip server verification when using TLS" (example: false)
|
||||
- name: "TLSCACertFile"
|
||||
in: "formData"
|
||||
type: "file"
|
||||
description: "TLS CA certificate file"
|
||||
- name: "TLSCertFile"
|
||||
in: "formData"
|
||||
type: "file"
|
||||
description: "TLS client certificate file"
|
||||
- name: "TLSKeyFile"
|
||||
in: "formData"
|
||||
type: "file"
|
||||
description: "TLS client key file"
|
||||
responses:
|
||||
200:
|
||||
description: "Success"
|
||||
|
@ -2143,7 +2176,7 @@ definitions:
|
|||
description: "Is analytics enabled"
|
||||
Version:
|
||||
type: "string"
|
||||
example: "1.17.0"
|
||||
example: "1.17.1"
|
||||
description: "Portainer API version"
|
||||
PublicSettingsInspectResponse:
|
||||
type: "object"
|
||||
|
@ -2344,10 +2377,22 @@ definitions:
|
|||
type: "string"
|
||||
example: "my-endpoint"
|
||||
description: "Endpoint name"
|
||||
Type:
|
||||
type: "integer"
|
||||
example: 1
|
||||
description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment."
|
||||
URL:
|
||||
type: "string"
|
||||
example: "docker.mydomain.tld:2375"
|
||||
description: "URL or IP address of the Docker host associated to this endpoint"
|
||||
PublicURL:
|
||||
type: "string"
|
||||
example: "docker.mydomain.tld:2375"
|
||||
description: "URL or IP address where exposed containers will be reachable"
|
||||
GroupID:
|
||||
type: "integer"
|
||||
example: 1
|
||||
description: "Endpoint group identifier"
|
||||
AuthorizedUsers:
|
||||
type: "array"
|
||||
description: "List of user identifiers authorized to connect to this endpoint"
|
||||
|
@ -2424,53 +2469,6 @@ definitions:
|
|||
type: "string"
|
||||
example: "hub_password"
|
||||
description: "Password used to authenticate against the DockerHub"
|
||||
EndpointCreateRequest:
|
||||
type: "object"
|
||||
required:
|
||||
- "Name"
|
||||
- "URL"
|
||||
properties:
|
||||
Name:
|
||||
type: "string"
|
||||
example: "my-endpoint"
|
||||
description: "Name that will be used to identify this endpoint"
|
||||
URL:
|
||||
type: "string"
|
||||
example: "docker.mydomain.tld:2375"
|
||||
description: "URL or IP address of a Docker host"
|
||||
PublicURL:
|
||||
type: "string"
|
||||
example: "docker.mydomain.tld:2375"
|
||||
description: "URL or IP address where exposed containers will be reachable.\
|
||||
\ Defaults to URL if not specified"
|
||||
TLS:
|
||||
type: "boolean"
|
||||
example: true
|
||||
description: "Require TLS to connect against this endpoint"
|
||||
TLSSkipVerify:
|
||||
type: "boolean"
|
||||
example: false
|
||||
description: "Skip server verification when using TLS"
|
||||
TLSSkipClientVerify:
|
||||
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:
|
||||
Id:
|
||||
type: "integer"
|
||||
example: 1
|
||||
description: "Id of the endpoint"
|
||||
EndpointListResponse:
|
||||
type: "array"
|
||||
items:
|
||||
|
|
|
@ -108,7 +108,7 @@
|
|||
</a>
|
||||
</span>
|
||||
<span ng-if="item.Mode === 'replicated' && item.Scale">
|
||||
<input class="input-sm" type="number" ng-model="item.Replicas" on-enter-key="$ctrl.scaleAction(item)" />
|
||||
<input class="input-sm" type="number" ng-model="item.Replicas" on-enter-key="$ctrl.scaleAction(item)" auto-focus/>
|
||||
<a class="interactive" ng-click="item.Scale = false;"><i class="fa fa-times"></i></a>
|
||||
<a class="interactive" ng-click="$ctrl.scaleAction(item)"><i class="fa fa-check-square"></i></a>
|
||||
</span>
|
||||
|
|
|
@ -60,8 +60,8 @@
|
|||
<tbody>
|
||||
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
|
||||
<td>
|
||||
<a ng-if="!$ctrl.agentProxy || !item.Container" ui-sref="docker.tasks.task({id: item.Id})" class="monospaced">{{ item.Service.Name }}{{ item.Slot ? '.' + item.Slot : '' }}{{ '.' + item.Id }}</a>
|
||||
<a ng-if="$ctrl.agentProxy && item.Container" ui-sref="docker.containers.container({ id: item.Container.Id, nodeName: item.Container.NodeName })" class="monospaced">{{ item.Service.Name }}{{ item.Slot ? '.' + item.Slot : '' }}{{ '.' + item.Id }}</a>
|
||||
<a ng-if="!$ctrl.agentProxy || !item.Container" ui-sref="docker.tasks.task({id: item.Id})" class="monospaced">{{ item.ServiceName }}{{ item.Slot ? '.' + item.Slot : '' }}{{ '.' + item.Id }}</a>
|
||||
<a ng-if="$ctrl.agentProxy && item.Container" ui-sref="docker.containers.container({ id: item.Container.Id, nodeName: item.Container.NodeName })" class="monospaced">{{ item.ServiceName }}{{ item.Slot ? '.' + item.Slot : '' }}{{ '.' + item.Id }}</a>
|
||||
</td>
|
||||
<td><span class="label label-{{ item.Status.State | taskstatusbadge }}">{{ item.Status.State }}</span></td>
|
||||
<td ng-if="$ctrl.showSlotColumn">{{ item.Slot ? item.Slot : '-' }}</td>
|
||||
|
@ -77,10 +77,10 @@
|
|||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.dataset">
|
||||
<td colspan="5" class="text-center text-muted">Loading...</td>
|
||||
<td colspan="6" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
||||
<td colspan="5" class="text-center text-muted">No task available.</td>
|
||||
<td colspan="6" class="text-center text-muted">No task available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
@ -4,6 +4,7 @@ angular.module('portainer.docker').component('logViewer', {
|
|||
bindings: {
|
||||
data: '=',
|
||||
displayTimestamps: '=',
|
||||
logCollectionChange: '<'
|
||||
logCollectionChange: '<',
|
||||
lineCount: '='
|
||||
}
|
||||
});
|
||||
|
|
|
@ -33,6 +33,14 @@
|
|||
<input class="form-control" type="text" name="logs_search" ng-model="$ctrl.state.search" ng-change="$ctrl.state.selectedLines.length = 0;" placeholder="Filter...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lines_count" class="col-sm-1 control-label text-left">
|
||||
Lines
|
||||
</label>
|
||||
<div class="col-sm-11">
|
||||
<input class="form-control" type="number" name="lines_count" ng-model="$ctrl.lineCount" placeholder="Enter no of lines...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.state.copySupported">
|
||||
<label class="col-sm-1 control-label text-left">
|
||||
Actions
|
||||
|
|
|
@ -11,7 +11,7 @@ angular.module('portainer.docker')
|
|||
var task = tasks[i];
|
||||
if (task.ServiceId === service.Id) {
|
||||
service.Tasks.push(task);
|
||||
task.Service = service;
|
||||
task.ServiceName = service.Name;
|
||||
} else {
|
||||
otherServicesTasks.push(task);
|
||||
}
|
||||
|
|
|
@ -6,5 +6,5 @@
|
|||
</rd-header>
|
||||
|
||||
<log-viewer
|
||||
data="logs" ng-if="logs" log-collection-change="changeLogCollection" display-timestamps="state.displayTimestamps"
|
||||
data="logs" ng-if="logs" log-collection-change="changeLogCollection" display-timestamps="state.displayTimestamps" line-count="state.lineCount"
|
||||
></log-viewer>
|
||||
|
|
|
@ -6,5 +6,5 @@
|
|||
</rd-header>
|
||||
|
||||
<log-viewer
|
||||
data="logs" ng-if="logs" log-collection-change="changeLogCollection" display-timestamps="state.displayTimestamps"
|
||||
data="logs" ng-if="logs" log-collection-change="changeLogCollection" display-timestamps="state.displayTimestamps" line-count="state.lineCount"
|
||||
></log-viewer>
|
||||
|
|
|
@ -6,5 +6,5 @@
|
|||
</rd-header>
|
||||
|
||||
<log-viewer
|
||||
data="logs" ng-if="logs" log-collection-change="changeLogCollection" display-timestamps="state.displayTimestamps"
|
||||
data="logs" ng-if="logs" log-collection-change="changeLogCollection" display-timestamps="state.displayTimestamps" line-count="state.lineCount"
|
||||
></log-viewer>
|
||||
|
|
|
@ -69,7 +69,7 @@
|
|||
</a>
|
||||
</span>
|
||||
<span ng-if="item.Mode === 'replicated' && item.Scale">
|
||||
<input class="input-sm" type="number" ng-model="item.Replicas" on-enter-key="$ctrl.scaleAction(item)" />
|
||||
<input class="input-sm" type="number" ng-model="item.Replicas" on-enter-key="$ctrl.scaleAction(item)" auto-focus/>
|
||||
<a class="interactive" ng-click="item.Scale = false;"><i class="fa fa-times"></i></a>
|
||||
<a class="interactive" ng-click="$ctrl.scaleAction(item)"><i class="fa fa-check-square"></i></a>
|
||||
</span>
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
groups="groups"
|
||||
select-endpoint="switchEndpoint"
|
||||
></endpoint-selector>
|
||||
<li class="sidebar-title"><span>{{ activeEndpoint.Name }}</span></li>
|
||||
<li class="sidebar-title"><span class="endpoint-name">{{ activeEndpoint.Name }}</span></li>
|
||||
<docker-sidebar-content
|
||||
endpoint-api-version="applicationState.endpoint.apiVersion"
|
||||
swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"
|
||||
|
|
|
@ -58,8 +58,8 @@ function ($q, $scope, $state, EndpointService, GroupService, StateManager, Endpo
|
|||
})
|
||||
.then(function success(data) {
|
||||
var endpoints = data.endpoints;
|
||||
$scope.groups = data.groups;
|
||||
$scope.endpoints = endpoints;
|
||||
$scope.groups = _.sortBy(data.groups, ['Name']);
|
||||
$scope.endpoints = _.sortBy(endpoints, ['Name']);
|
||||
|
||||
setActiveEndpoint(endpoints);
|
||||
|
||||
|
|
|
@ -304,6 +304,10 @@ ul.sidebar .sidebar-title {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-title .endpoint-name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list a {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
Name: portainer
|
||||
Version: 1.17.0
|
||||
Version: 1.17.1
|
||||
Release: 0
|
||||
License: Zlib
|
||||
Summary: A lightweight docker management UI
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
|
Loading…
Reference in New Issue