Skip to content

Commit 87eb986

Browse files
committed
Merge branch 'release/1.5.0'
2 parents 1c7546d + 4388f83 commit 87eb986

23 files changed

Lines changed: 291 additions & 84 deletions

File tree

Gopkg.lock

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Gopkg.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676

7777
[[constraint]]
7878
name = "github.com/portainer/libhttp"
79-
version = "=1.0.1"
79+
version = "=1.1.0"
8080

8181
[[constraint]]
8282
name = "github.com/portainer/libcrypto"

README.md

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ Containers, networks, volumes and images are node specific resources, not cluste
1010

1111
The purpose of the agent aims to allows previously node specific resources to be cluster-aware, all while keeping the Docker API request format. As aforementioned, this means that you only need to execute one Docker API request to retrieve all these resources from every node inside the cluster. In all bringing a better Docker user experience when managing Swarm clusters.
1212

13+
## Security
14+
15+
Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to <security@portainer.io>.
16+
1317
## Technical details
1418

1519
The Portainer agent is basically a cluster of Docker API proxies. Deployed inside a Swarm cluster on each node, it allows the
@@ -22,6 +26,11 @@ At startup, the agent will communicate with the Docker node it is deployed on vi
2226
This implementation is using *serf* to form a cluster over a network, each agent requires an address where it will advertise its
2327
ability to be part of a cluster and a join address where it will be able to reach other agents.
2428

29+
The agent retrieves the IP address it can use to create
30+
a cluster by inspecting the Docker networks associated to the agent container. If multiple networks are available, it will pickup the first network available and retrieve the IP address inside this network.
31+
32+
Note: Be careful when deploying the agent to not deploy it inside the Swarm ingress network (by not using `mode=host` when exposing ports). This could lead to the agent being unable to create a cluster correctly, if picking the IP address inside the ingress network.
33+
2534
### Proxy
2635

2736
The agent works as a proxy to the Docker API on which it is deployed as well as a proxy to the other agents inside the cluster.
@@ -72,6 +81,10 @@ The agent also exposes the following endpoints:
7281
* `/browse/put` (*POST*): Upload a file under a specific path on the filesytem
7382
* `/host/info` (*GET*): Get information about the underlying host system
7483
* `/ping` (*GET*): Returns a 204. Public endpoint that do not require any form of authentication
84+
* `/key` (*GET*): Returns the Edge key associated to the agent **only available when agent is started in Edge mode**
85+
* `/key` (*POST*): Set the Edge key on this agent **only available when agent is started in Edge mode**
86+
* `/websocket/attach` (*GET*): Websocket attach endpoint (for container console usage)
87+
* `/websocket/exec` (*GET*): Websocket exec endpoint (for container console usage)
7588

7689
Note: The `/browse/*` endpoints can be used to manage a filesystem. By default, it allows manipulation of files in Docker volumes (available under `/var/run/docker/volumes` when bind-mounted in the agent container) but can also manipulate files anywhere on the filesystem. To enable global
7790
filesystem manipulation support for these endpoints, the `CAP_HOST_MANAGEMENT` environment variable must be set to `1`.
@@ -80,7 +93,87 @@ filesystem manipulation support for these endpoints, the `CAP_HOST_MANAGEMENT` e
8093

8194
The agent API version is exposed via the `Portainer-Agent-API-Version` in each response of the agent.
8295

83-
## Security
96+
## Using the agent in Edge mode
97+
98+
The following information is only relevant for an Agent that was started in Edge mode.
99+
100+
### Purpose
101+
102+
The Edge mode is mainly used in the case of your remote environment being not in the same network as your Portainer instance. When started in Edge mode, the agent will reach out to the Portainer instance
103+
and will take care of creating a reverse tunnel allowing the Portainer instance to query it. It uses a token (Edge key) that contains the required information to connect to a specific Portainer instance.
104+
105+
### Startup
106+
107+
To start an agent in Edge mode, the `EDGE=1` environment variable must be set.
108+
109+
Upon startup, the agent will try to retrieve an existing Edge key in the following order:
110+
111+
* from the environment variables via the `EDGE_KEY` environment variable
112+
* from the filesystem (see the Edge key section below for more information about key persistence on disk)
113+
* from the cluster (if joining an existing Edge agent cluster)
114+
115+
If no Edge key was retrieved, the agent will start a HTTP server where it will expose a UI to associate an Edge key. After associating a key via the UI, the UI server will shutdown.
116+
117+
For security reasons, the Edge server UI will shutdown after 15 minutes if no key has been specified. The agent will require a restart in order
118+
to access the Edge UI again.
119+
120+
### Edge key
121+
122+
The Edge key is used by the agent to connect to a specific Portainer instance. It is encoded using base64 and contains the following information:
123+
124+
* Portainer instance API URL
125+
* Portainer instance tunnel server address
126+
* Portainer instance tunnel server fingerprint
127+
* Endpoint identifier
128+
129+
This information is represented in the following format before encoding (single string using the `|` character as a separator):
130+
131+
```
132+
portainer_instance_url|tunnel_server_addr|tunnel_server_fingerprint|endpoint_ID
133+
```
134+
135+
The Edge key associated to an agent will be persisted on disk after association under `/data/agent_edge_key`.
136+
137+
### Polling
138+
139+
After associating an Edge key to an agent, the agent will start polling the associated Portainer instance.
140+
141+
It will use the Portainer instance API URL and the endpoint identifier available in the Edge key to build the poll request URL: `http(s)://API_URL/api/endpoints/ENDPOINT_ID/status`
142+
143+
The response of the poll request contains the following information:
144+
145+
* Tunnel status
146+
* Poll frequency
147+
* Tunnel port
148+
* Encrypted credentials
149+
* Schedules
150+
151+
The tunnel status property can take one of the following values: `IDLE`, `REQUIRED`, `ACTIVE`. When this property is set to `REQUIRED`, the agent will
152+
create a reverse tunnel to the Portainer instance using the port specified in the response as well as the credentials.
153+
154+
Each poll request sent to the Portainer instance contains the `X-PortainerAgent-EdgeID` header (with the value set to the Edge ID associated to the agent). This is used by the Portainer instance to associate an Edge ID to an endpoint so that an agent won't be able to poll information and join an Edge cluster by re-using an existing key without knowing the Edge ID.
155+
156+
To allow for pre-staged environments, this Edge ID is associated to an endpoint by Portainer after receiving the first poll request from an agent.
157+
158+
### Reverse tunnel
159+
160+
The reverse tunnel is established by the agent. The permissions associated to the credentials are set on the Portainer instance, the credentials are valid for a management session and can only be used
161+
to create a reverse tunnel on a specific port (the one that is specified in the poll response).
162+
163+
The agent will monitor the usage of the tunnel. The tunnel will be closed in any of the following cases:
164+
165+
1. The status of the tunnel specified in the poll response is equal to `IDLE`
166+
2. If no activity has been registered on the tunnel (no requests executed against the agent API) after a specific amount of time (can be configured via `EDGE_INACTIVITY_TIMEOUT`, default to 5 minutes)
167+
168+
### API server
169+
170+
When deployed in Edge mode, the agent API is not exposed over HTTPS anymore (see Using the agent non Edge section below) because we're using SSH to setup an encrypted tunnel. In order to avoid potential security issues with agent deployment exposing the API port on their host, the agent won't expose the API server under 0.0.0.0. Instead, it will expose the API server on the same IP address that is used to advertise the cluster (usually, the container IP in the overlay network).
171+
172+
This means that only a container deployed in the same overlay network as the agent will be able to query it.
173+
174+
## Using the agent (non Edge)
175+
176+
The following information is only relevant for an Agent that was not started in Edge mode.
84177

85178
### Encryption
86179

@@ -132,17 +225,25 @@ This mode will allow multiple instances of Portainer to connect to a single agen
132225

133226
Note: Due to the fact that the agent will now decode and parse the public key associated to each request, this mode might be less performant than the default mode.
134227

135-
136228
## Deployment options
137229

138230
The behavior of the agent can be tuned via a set of mandatory and optional options available as environment variables:
139231

140232
* AGENT_CLUSTER_ADDR (*mandatory*): address (in the IP:PORT format) of an existing agent to join the agent cluster. When deploying the agent as a Docker Swarm service,
141233
we can leverage the internal Docker DNS to automatically join existing agents or form a cluster by using `tasks.<AGENT_SERVICE_NAME>:<AGENT_PORT>` as the address.
142-
* AGENT_PORT (*optional*): port on which the agent web server will listen (default to `9001`).
143-
* CAP_HOST_MANAGEMENT (*optional*): enable advanced filesystem management features. Disabled by default, set to `1` to enable it.
234+
* AGENT_HOST (*optional*): address on which the agent API will be exposed (default to `0.0.0.0`)
235+
* AGENT_PORT (*optional*): port on which the agent API will be exposed (default to `9001`)
236+
* CAP_HOST_MANAGEMENT (*optional*): enable advanced filesystem management features. Disabled by default, set to `1` to enable it
144237
* AGENT_SECRET (*optional*): shared secret used in the signature verification process
145238
* LOG_LEVEL (*optional*): defines the log output verbosity (default to `INFO`)
239+
* EDGE (*optional*): enable Edge mode. Disabled by default, set to `1` to enable it
240+
* EDGE_KEY (*optional*): specify an Edge key to use at startup
241+
* EDGE_ID (*mandatory when EDGE=1*): a unique identifier associated to this agent cluster
242+
* EDGE_SERVER_HOST (*optional*): address on which the Edge UI will be exposed (default to `0.0.0.0`)
243+
* EDGE_SERVER_PORT (*optional*): port on which the Edge UI will be exposed (default to `80`).
244+
* EDGE_INACTIVITY_TIMEOUT (*optional*): timeout used by the agent to close the reverse tunnel after inactivity (default to `5m`)
245+
* EDGE_INSECURE_POLL (*optional*): enable this option if you need the agent to poll a HTTPS Portainer instance with self-signed certificates. Disabled by default, set to `1` to enable it
246+
146247

147248
For more information about deployment scenarios, see: https://portainer.readthedocs.io/en/stable/agent.html
148249

agent.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ type (
1414
EdgeServerAddr string
1515
EdgeServerPort string
1616
EdgeInactivityTimeout string
17-
EdgePollFrequency string
1817
EdgeInsecurePoll bool
1918
LogLevel string
2019
}
@@ -98,6 +97,7 @@ type (
9897
InfoService interface {
9998
GetInformationFromDockerEngine() (map[string]string, error)
10099
GetContainerIpFromDockerEngine(containerName string) (string, error)
100+
GetServiceNameFromDockerEngine(containerName string) (string, error)
101101
}
102102

103103
// TLSService is used to create TLS certificates to use enable HTTPS.
@@ -138,7 +138,7 @@ type (
138138

139139
const (
140140
// Version represents the version of the agent.
141-
Version = "1.4.0"
141+
Version = "1.5.0"
142142
// APIVersion represents the version of the agent's API.
143143
APIVersion = "2"
144144
// DefaultAgentAddr is the default address used by the Agent API server.

cmd/agent/main.go

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,46 +41,57 @@ func main() {
4141
log.Println("[INFO] [main] [message: Agent running on a Swarm cluster node. Running in cluster mode]")
4242
}
4343

44-
if options.ClusterAddress == "" && clusterMode {
45-
log.Fatalf("[ERROR] [main,configuration] [message: AGENT_CLUSTER_ADDR environment variable is required when deploying the agent inside a Swarm cluster]")
44+
containerName, err := os.GetHostName()
45+
if err != nil {
46+
log.Fatalf("[ERROR] [main,os] [message: Unable to retrieve container name] [error: %s]", err)
4647
}
4748

48-
advertiseAddr, err := retrieveAdvertiseAddress(&infoService)
49+
advertiseAddr, err := infoService.GetContainerIpFromDockerEngine(containerName)
4950
if err != nil {
50-
log.Fatalf("[ERROR] [main,docker,os] [message: Unable to retrieve local agent IP address] [error: %s]", err)
51+
log.Fatalf("[ERROR] [main,docker] [message: Unable to retrieve local agent IP address] [error: %s]", err)
5152
}
5253

5354
var clusterService agent.ClusterService
5455
if clusterMode {
5556
clusterService = cluster.NewClusterService(agentTags)
5657

58+
clusterAddr := options.ClusterAddress
59+
if clusterAddr == "" {
60+
serviceName, err := infoService.GetServiceNameFromDockerEngine(containerName)
61+
if err != nil {
62+
log.Fatalf("[ERROR] [main,docker] [message: Unable to agent service name from Docker] [error: %s]", err)
63+
}
64+
65+
clusterAddr = fmt.Sprintf("tasks.%s", serviceName)
66+
}
67+
5768
// TODO: Workaround. looks like the Docker DNS cannot find any info on tasks.<service_name>
5869
// sometimes... Waiting a bit before starting the discovery (at least 3 seconds) seems to solve the problem.
5970
time.Sleep(3 * time.Second)
6071

61-
joinAddr, err := net.LookupIPAddresses(options.ClusterAddress)
72+
joinAddr, err := net.LookupIPAddresses(clusterAddr)
6273
if err != nil {
63-
log.Fatalf("[ERROR] [main,net] [host: %s] [message: Unable to retrieve a list of IP associated to the host] [error: %s]", options.ClusterAddress, err)
74+
log.Fatalf("[ERROR] [main,net] [host: %s] [message: Unable to retrieve a list of IP associated to the host] [error: %s]", clusterAddr, err)
6475
}
6576

6677
err = clusterService.Create(advertiseAddr, joinAddr)
6778
if err != nil {
6879
log.Fatalf("[ERROR] [main,cluster] [message: Unable to create cluster] [error: %s]", err)
6980
}
7081

82+
log.Printf("[DEBUG] [main,configuration] [agent_port: %s] [cluster_address: %s] [advertise_address: %s]", options.AgentServerPort, clusterAddr, advertiseAddr)
83+
7184
defer clusterService.Leave()
7285
}
7386

74-
log.Printf("[DEBUG] [main,configuration] [agent_port: %s] [cluster_address: %s] [advertise_address: %s]", options.AgentServerPort, options.ClusterAddress, advertiseAddr)
75-
7687
var tunnelOperator agent.TunnelOperator
7788
if options.EdgeMode {
7889
apiServerAddr := fmt.Sprintf("%s:%s", advertiseAddr, options.AgentServerPort)
7990

8091
operatorConfig := &tunnel.OperatorConfig{
8192
APIServerAddr: apiServerAddr,
8293
EdgeID: options.EdgeID,
83-
PollFrequency: options.EdgePollFrequency,
94+
PollFrequency: agent.DefaultEdgePollInterval,
8495
InactivityTimeout: options.EdgeInactivityTimeout,
8596
InsecurePoll: options.EdgeInsecurePoll,
8697
}
@@ -281,17 +292,3 @@ func retrieveInformationFromDockerEnvironment(infoService agent.InfoService) (ma
281292

282293
return agentTags, nil
283294
}
284-
285-
func retrieveAdvertiseAddress(infoService agent.InfoService) (string, error) {
286-
containerName, err := os.GetHostName()
287-
if err != nil {
288-
return "", err
289-
}
290-
291-
advertiseAddr, err := infoService.GetContainerIpFromDockerEngine(containerName)
292-
if err != nil {
293-
return "", err
294-
}
295-
296-
return advertiseAddr, nil
297-
}

dev.sh

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
LOG_LEVEL=DEBUG
44
CAP_HOST_MANAGEMENT=1 #Enabled by default. Change this to anything else to disable this feature
5-
EDGE=1
5+
EDGE=0
66
TMP="/tmp"
77
GIT_COMMIT_HASH=`git rev-parse --short HEAD`
88
GIT_BRANCH_NAME=`git rev-parse --abbrev-ref HEAD`
@@ -84,21 +84,19 @@ function deploy_swarm() {
8484

8585
echo "Deployment..."
8686

87-
docker -H "${DOCKER_MANAGER}:2375" network create --driver overlay --attachable portainer-agent-dev-net
87+
docker -H "${DOCKER_MANAGER}:2375" network create --driver overlay portainer-agent-dev-net
8888
docker -H "${DOCKER_MANAGER}:2375" service create --name portainer-agent-dev \
8989
--network portainer-agent-dev-net \
9090
-e LOG_LEVEL="${LOG_LEVEL}" \
9191
-e CAP_HOST_MANAGEMENT=${CAP_HOST_MANAGEMENT} \
9292
-e EDGE=${EDGE} \
9393
-e EDGE_ID=${EDGE_ID} \
94-
-e AGENT_CLUSTER_ADDR=tasks.portainer-agent-dev \
9594
--mode global \
9695
--mount type=bind,src=//var/run/docker.sock,dst=/var/run/docker.sock \
9796
--mount type=bind,src=//var/lib/docker/volumes,dst=/var/lib/docker/volumes \
9897
--mount type=bind,src=//,dst=/host \
99-
--publish mode=host,target=9001,published=9001 \
98+
--publish target=9001,published=9001 \
10099
--publish mode=host,published=80,target=80 \
101-
--restart-condition none \
102100
"${IMAGE_NAME}"
103101

104102
# --mount type=volume,src=portainer_agent_data,dst=/data \

docker/docker.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ import (
55
"errors"
66
"log"
77

8+
"github.com/docker/docker/api/types"
89
"github.com/docker/docker/client"
910
"github.com/portainer/agent"
1011
)
1112

13+
const (
14+
serviceNameLabel = "com.docker.swarm.service.name"
15+
)
16+
1217
// InfoService is a service used to retrieve information from a Docker environment.
1318
type InfoService struct{}
1419

@@ -58,12 +63,43 @@ func (service *InfoService) GetContainerIpFromDockerEngine(containerName string)
5863
return "", err
5964
}
6065

61-
for _, network := range containerInspect.NetworkSettings.Networks {
66+
if len(containerInspect.NetworkSettings.Networks) > 1 {
67+
log.Printf("[WARN] [docker] [network_count: %d] [message: Agent container running in more than a single Docker network. This might cause communication issues]", len(containerInspect.NetworkSettings.Networks))
68+
}
69+
70+
for networkName, network := range containerInspect.NetworkSettings.Networks {
71+
networkInspect, err := cli.NetworkInspect(context.Background(), network.NetworkID, types.NetworkInspectOptions{})
72+
if err != nil {
73+
return "", err
74+
}
75+
76+
if networkInspect.Ingress || networkInspect.Scope != "swarm" {
77+
log.Printf("[DEBUG] [docker] [network_name: %s] [scope: %s] [ingress: %t] [message: Skipping invalid container network]", networkInspect.Name, networkInspect.Scope, networkInspect.Ingress)
78+
continue
79+
}
80+
6281
if network.IPAddress != "" {
63-
log.Printf("[DEBUG] [docker] [network_count: %d] [ip_address: %s] [message: Retrieving IP address from container networks]", len(containerInspect.NetworkSettings.Networks), network.IPAddress)
82+
log.Printf("[DEBUG] [docker] [ip_address: %s] [network_name: %s] [message: Retrieving IP address from container network]", network.IPAddress, networkName)
6483
return network.IPAddress, nil
6584
}
6685
}
6786

6887
return "", errors.New("unable to retrieve the address on which the agent can advertise. Check your network settings")
6988
}
89+
90+
// GetServiceNameFromDockerEngine is used to return the name of the Swarm service the agent is part of.
91+
// The service name is retrieved through container labels.
92+
func (service *InfoService) GetServiceNameFromDockerEngine(containerName string) (string, error) {
93+
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithVersion(agent.SupportedDockerAPIVersion))
94+
if err != nil {
95+
return "", err
96+
}
97+
defer cli.Close()
98+
99+
containerInspect, err := cli.ContainerInspect(context.Background(), containerName)
100+
if err != nil {
101+
return "", err
102+
}
103+
104+
return containerInspect.Config.Labels[serviceNameLabel], nil
105+
}

0 commit comments

Comments
 (0)