Compare commits

...
13 Commits
Author SHA1 Message Date
ElevenNotes 2e3aa6ea57 exception handling 2024-01-25 14:54:14 +01:00
ElevenNotes 962cd22f26 0.2.0 > latest 2024-01-25 09:19:13 +01:00
ElevenNotes 9f24499f6f verbosity 2024-01-25 09:18:26 +01:00
ElevenNotes 33cf74e52a verbosity 2024-01-24 23:36:32 +01:00
ElevenNotes 543a19f5f0 js-yaml 2024-01-24 22:52:48 +01:00
ElevenNotes 5ad1a0d355 client > server 2024-01-24 22:28:19 +01:00
ElevenNotes 307e7101f8 add TLS defaults 2024-01-23 22:52:36 +01:00
ElevenNotes 6752ebee1a 0.1.10 2024-01-23 22:08:09 +01:00
ElevenNotes 3c2ab08ba9 0.1.9 2024-01-23 22:07:09 +01:00
ElevenNotes 498c0988e5 0.1.10 2024-01-23 22:01:53 +01:00
ElevenNotes 49801cd521 0.1.10 2024-01-23 22:01:10 +01:00
ElevenNotes 66b5bab4ae add TLS auth 2024-01-23 21:58:09 +01:00
ElevenNotes 070f45fae5 add TLS authentication 2024-01-23 15:34:43 +01:00
6 changed files with 223 additions and 120 deletions
+36 -5
View File
@@ -1,5 +1,5 @@
# Alpine :: Traefik Labels
![size](https://img.shields.io/docker/image-size/11notes/traefik-labels/amd64-0.1.9?color=0eb305) ![version](https://img.shields.io/docker/v/11notes/traefik-labels/amd64-0.1.9?color=eb7a09) ![pulls](https://img.shields.io/docker/pulls/11notes/traefik-labels?color=2b75d6) ![activity](https://img.shields.io/github/commit-activity/m/11notes/docker-traefik-labels?color=c91cb8) ![commit-last](https://img.shields.io/github/last-commit/11notes/docker-traefik-labels?color=c91cb8)
![size](https://img.shields.io/docker/image-size/11notes/traefik-labels/amd64-0.1.10?color=0eb305) ![version](https://img.shields.io/docker/v/11notes/traefik-labels/amd64-0.1.10?color=eb7a09) ![pulls](https://img.shields.io/docker/pulls/11notes/traefik-labels?color=2b75d6) ![activity](https://img.shields.io/github/commit-activity/m/11notes/docker-traefik-labels?color=c91cb8) ![commit-last](https://img.shields.io/github/last-commit/11notes/docker-traefik-labels?color=c91cb8)
Run Traefik Labels based on Alpine Linux. Small, lightweight, secure and fast 🏔️
@@ -10,6 +10,9 @@ In order to use this image, you need to setup Traefik with a Redis provider and
This image provides the ability to call a webhook for each container for each event or poll after the data was updates in Redis and or nsupdate.
## Volumes
* **/labels/ssl** - Directory of ssl certificates for TLS<sup>1</sup>
## Run
```shell
docker run --name traefik-labels \
@@ -27,6 +30,15 @@ docker run --name traefik-labels \
-d 11notes/traefik-labels:[tag]
```
```shell
docker run --name traefik-labels \
-v .../ssl:/labels/ssl \
-e LABELS_REDIS_URL="rediss://foo:bar@10.127.198.254:6379/0" \
-e LABELS_WEBHOOK="https://domain.com/traefik/labels" \
-e LABELS_WEBHOOK_AUTH_BASIC="foo:bar" \
-d 11notes/traefik-labels:[tag]
```
## Examples
```shell
docker run --name traefik-demo \
@@ -65,18 +77,23 @@ docker run --name traefik-rfc2136-demo \
| `gid` | 1000 | group id 1000 |
| `home` | /labels | home directory of user docker |
| `api` | https://${IP}:5000 | HTTPS endpoint of Docker registry |
| `ca.crt` | /labels/ssl/ca.crt | Certificate of CA for TLS<sup>1</sup> |
| `labels.crt` | /labels/ssl/labels.crt | Certificate of client for TLS<sup>1</sup> |
| `labels.key` | /labels/ssl/labels.key | Private key of client for TLS<sup>1</sup> |
## Environment
| Parameter | Value | Default |
| --- | --- | --- |
| `TZ` | [Time Zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) | null |
| `DEBUG` | Show debug information | null |
| `TZ` | [Time Zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) | |
| `DEBUG` | Show debug information | |
| `LABELS_REDIS_URL` | the redis URL to connect, use rediss:// for SSL | rediss://localhost:6379/0 |
| `LABELS_INTERVAL` | in what interval container information is pulled in seconds | 300 |
| `LABELS_TIMEOUT` | how many seconds after an interval the keys should stay till they expire in seconds | 30 |
| `LABELS_WEBHOOK` | URL to call on each event or poll for each container | |
| `LABELS_WEBHOOK_AUTH_BASIC` | Basic authentication to use in the form of "username:password" for the webhook | |
| `LABELS_RFC2136_ONLY_UPDATE_ON_CHANGE` | Only update DNS entries if they are new or changed (will use dig on each call to the set server) | false |
| `LABELS_RFC2136_ONLY_UPDATE_ON_CHANGE` | Only update DNS entries if they are new or changed (will use dig on each call to the set server) | |
| `LABELS_DOCKER_IP` | Used for TLS<sup>1</sup> authentication against dockerd | localhost |
| `LABELS_DOCKER_PORT` | Used for TLS<sup>1</sup> authentication against dockerd | 2376 |
## Parent image
* [11notes/node:stable](https://hub.docker.com/r/11notes/node)
@@ -90,4 +107,18 @@ docker run --name traefik-rfc2136-demo \
## Tips
* Only use rootless container runtime (podman, rootless docker)
* Don't bind to ports < 1024 (requires root), use NAT/reverse proxy (haproxy, traefik, nginx)
* Do not access docker.sock as root
* Do not access docker.sock as root
* Use TLS if you can't access docker.sock as non-root
## Disclaimers
* <sup>1</sup> For TLS to work you need proper certificates in place for your dockerd and your clients. The CN in the certificate needs to match the FQDN or IP you have set on the docker node, you can set multiple by using SAN. See an example of a daemon.json configuration to enable TLS.
```json
{
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/ca.crt",
"tlscert": "/etc/docker/server.crt",
"tlskey": "/etc/docker/server.key",
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"]
}
```
+1
View File
@@ -27,6 +27,7 @@
cd ${APP_ROOT}; \
npm --save install \
redis@4.6.11 \
js-yaml@4.1.0 \
dockerode@4.0.0;
# :: update image
+1
View File
@@ -33,6 +33,7 @@
cd ${APP_ROOT}; \
npm --save install \
redis@4.6.11 \
js-yaml@4.1.0 \
dockerode@4.0.0;
# :: update image
+2 -2
View File
@@ -1,10 +1,10 @@
image: 11notes/traefik-labels:latest
manifests:
- image: 11notes/traefik-labels:amd64-0.1.9
- image: 11notes/traefik-labels:amd64-0.2.0
platform:
architecture: amd64
os: linux
- image: 11notes/traefik-labels:arm64v8-0.1.9
- image: 11notes/traefik-labels:arm64v8-0.2.0
platform:
architecture: arm64
os: linux
+20
View File
@@ -0,0 +1,20 @@
labels:
redis:
url: rediss://foo:bar@10.127.198.254:6379/0
webhook:
url: https://my.cool.webhook/v1
auth:
basic: labels:*****
nodes:
- 192.168.18.12
- 10.14.120.1
rfc2136:
update-only: true
poll:
interval: 300
ping:
interval: 2.5
tls:
ca: /labels/ssl/ca.crt
crt: /labels/ssl/server.crt
key: /labels/ssl/server.key
+163 -113
View File
@@ -1,128 +1,179 @@
process.once('SIGTERM', () => process.exit(0));
process.once('SIGINT', () => process.exit(0));
const fs = require('fs');
const util = require('util');
const Docker = require('dockerode');
const yaml = require('js-yaml');
const redis = require('redis');
const { nsupdate } = require('./nsupdate');
const { dig } = require('./dig');
const { elevenLogJSON } = require('/labels/lib/util.js');
const ENV_REDIS_INTERVAL = parseInt(process.env?.LABELS_INTERVAL || 300);
const ENV_REDIS_TIMEOUT = parseInt(process.env?.LABELS_TIMEOUT|| 30);
const ENV_LABELS_WEBHOOK = process.env?.LABELS_WEBHOOK;
const ENV_LABELS_WEBHOOK_AUTH_BASIC = process.env?.LABELS_WEBHOOK_AUTH_BASIC;
const ENV_LABELS_RFC2136_ONLY_UPDATE_ON_CHANGE = process.env?.LABELS_RFC2136_ONLY_UPDATE_ON_CHANGE || false;
elevenLogJSON('info', {config:{
LABELS_INTERVAL :ENV_REDIS_INTERVAL,
LABELS_TIMEOUT:ENV_REDIS_TIMEOUT,
LABELS_WEBHOOK:ENV_LABELS_WEBHOOK,
LABELS_WEBHOOK_AUTH_BASIC:((ENV_LABELS_WEBHOOK_AUTH_BASIC) ? true: false),
LABELS_RFC2136_ONLY_UPDATE_ON_CHANGE:ENV_LABELS_RFC2136_ONLY_UPDATE_ON_CHANGE
}});
class Labels{
#docker;
#config = yaml.load(fs.readFileSync(`${process.env.APP_ROOT}/etc/config.yaml`, 'utf8'))?.labels;
#defaults = {
tls:{
ca:`${process.env.APP_ROOT}/ssl/ca.crt`,
crt:`${process.env.APP_ROOT}/ssl/labels.crt`,
key:`${process.env.APP_ROOT}/ssl/labels.key`,
port:2376
},poll:{interval:300}, ping:{interval:2.5}, redis:{url:'rediss://localhost:6379/0'}, rfc2136:{'update-only':false}};
#intervals = {ping:false, poll:false};
#loops = {ping:false, poll:false};
#redis;
#poll = false;
#webhook = {
headers:{'Content-Type':'application/json'}
};
#nodes = {};
constructor(){
this.#docker = new Docker({socketPath:'/run/docker.sock'});
if(ENV_LABELS_WEBHOOK_AUTH_BASIC){
this.#webhook.headers['Authorization'] = 'Basic ' + Buffer.from(ENV_LABELS_WEBHOOK_AUTH_BASIC).toString('base64')
for(const node of this.#config?.nodes){
this.#nodes[node] = new Docker({
protocol:'https',
host:node,
port:this.#config?.tls?.port || this.#defaults.tls.port,
ca:fs.readFileSync(this.#config?.tls?.ca || this.#defaults.tls.ca),
cert:fs.readFileSync(this.#config?.tls?.crt || this.#defaults.tls.crt),
key:fs.readFileSync(this.#config?.tls?.key || this.#defaults.tls.key),
});
this.#nodes[node].labels = {ping:false, firstConnect:true};
}
if(this.#config?.webhook?.url){
this.#config.webhook.headers = {'Content-Type':'application/json'};
switch(true){
case this.#config?.webhook?.auth?.basic:
this.#config.webhook.headers['Authorization'] = 'Basic ' + Buffer.from(this.#config.webhook.auth.basic).toString('base64');
break;
}
}
}
async watch(){
async watch(){
this.#redis = await redis.createClient({
url:process.env.LABELS_REDIS_URL,
url:this.#config?.redis?.url || this.#defaults.redis.url,
pingInterval:30000,
socket:{
rejectUnauthorized: false,
}
});
this.#redis.connect();
this.#redis.on('ready', ()=>{
(async() => {
await this.dockerPoll();
})();
this.dockerEvents();
this.#redis.on('ready', async()=>{
elevenLogJSON('info', `connected to redis`);
await this.#ping();
await this.#poll();
});
this.#redis.on('error', error =>{
elevenLogJSON('error', error);
elevenLogJSON('error', JSON.stringify({redis:{exception:e.toString()}}));
});
setInterval(async() => {
await this.dockerPoll();
}, ENV_REDIS_INTERVAL*1000);
this.#redis.connect();
}
dockerEvents(){
this.#docker.getEvents({}, (error, data) => {
if(error){
elevenLogJSON('error', error);
}else{
data.on('data', async(chunk) => {
const event = JSON.parse(chunk.toString('utf8'));
if(/Container/i.test(event?.Type) && /^(start|kill)$/i.test(event?.status)){
await this.dockerInspect(event.id, event.status);
async #ping(){
if(!this.#intervals.ping){
this.#intervals.ping = true;
setInterval(async() => {
if(!this.#loops.ping){
this.#loops.ping = true;
try{
await this.#ping()
}catch(e){
elevenLogJSON('error', JSON.stringify({ping:{exception:e.toString()}}));
}finally{
this.#loops.ping = false;
}
});
}
});
}
}
}, (this.#config?.ping?.interval || this.#defaults.ping.interval)*1000);
}
async dockerPoll(){
if(!this.#poll){
for(const node in this.#nodes){
try{
this.#poll = true;
this.#docker.listContainers((error, containers) => {
if(!error){
containers.forEach(async(container) => {
await this.dockerInspect(container.Id, 'poll');
});
}
});
await this.#nodes[node].ping();
if(!this.#nodes[node].labels.ping){
elevenLogJSON('info', `connected to node [${node}]`);
this.#nodes[node].getEvents({}, (error, data) => {
if(!error){
data.on('data', async(chunk) =>{
const event = JSON.parse(chunk.toString('utf8'));
if(/Container/i.test(event?.Type) && /^(start|die)$/i.test(event?.status)){
await this.#inspect(node, event.id, event.status);
}
});
}else{
elevenLogJSON('error', JSON.stringify({getEvents:{exception:error.toString()}}));
}
});
}
this.#nodes[node].labels.ping = true;
}catch(e){
elevenLogJSON('error', e);
}finally{
this.#poll = false;
elevenLogJSON('error', JSON.stringify({ping:{exception:e.toString()}}));
if(this.#nodes[node].labels.ping){
elevenLogJSON('warning', `connection to node [${node}] lost!`);
}else if(this.#nodes[node].labels.firstConnect){
this.#nodes[node].labels.firstConnect = false;
elevenLogJSON('warning', `connection to node [${node}] failed!`);
}
this.#nodes[node].labels.ping = false;
}
}
}
async dockerInspect(id, status = null){
async #poll(){
if(!this.#intervals.poll){
this.#intervals.poll = true;
setInterval(async() => {
if(!this.#loops.poll){
this.#loops.poll = true;
try{
await this.#poll()
}catch(e){
elevenLogJSON('error', JSON.stringify({poll:{exception:e.toString()}}));
}finally{
this.#loops.poll = false;
}
}
}, (this.#config?.poll?.interval || this.#defaults.poll.interval)*1000);
}
for(const node in this.#nodes){
try{
await this.#nodes[node].listContainers((error, containers) => {
if(!error){
containers.forEach(async(container) => {
await this.#inspect(node, container.Id, 'poll');
});
}
});
}catch(e){
elevenLogJSON('error', JSON.stringify({listContainers:{exception:e.toString()}}));
}
}
}
async #inspect(node, id, event){
return(new Promise((resolve, reject) => {
const container = this.#docker.getContainer(id);
const container = this.#nodes[node].getContainer(id);
container.inspect(async(error, data) => {
if(!error){
const update = (/start|poll/i.test(status)) ? true : false;
const container = {
name:(data?.Name || data?.id).replace(/^\//i, ''),
event:status,
event:event,
run:(/start|poll/i.test(event)) ? true : false,
labels:{
traefik:[],
rfc2136:[],
},
};
elevenLogJSON('info', `[${node}] container [${container.name}] event [${container.event}]`);
const rfc2136 = {
WAN:{server:'', key:'', commands:[]},
LAN:{server:'', key:'', commands:[]},
}
elevenLogJSON('info', {container:container.name, event:status, method:'inspect'});
for(const label in data?.Config?.Labels){
switch(true){
case /traefik\//i.test(label):
if(update){
await this.#redis.set(label, data.Config.Labels[label], {EX:ENV_REDIS_INTERVAL + ENV_REDIS_TIMEOUT});
if(container.run){
await this.#redis.set(label, data.Config.Labels[label], {EX:(this.#config?.poll?.interval || this.#defaults.poll.interval) + 30});
}else{
await this.#redis.del(label);
}
@@ -142,7 +193,7 @@ class Labels{
break;
default:
if(!update){
if(!container.run){
data.Config.Labels[label] = data.Config.Labels[label].replace(/update add/i, 'update delete');
}
rfc2136[type].commands.push(data.Config.Labels[label]);
@@ -151,62 +202,61 @@ class Labels{
}
}
for(const type in rfc2136){
if(rfc2136[type].commands.length > 0 && rfc2136[type].server && rfc2136[type].key){
if(ENV_LABELS_RFC2136_ONLY_UPDATE_ON_CHANGE){
for(let i=0; i<rfc2136[type].commands.length; i++){
if(await this.rfc2136KnownRecord(rfc2136[type].server, rfc2136[type].commands[i])){
rfc2136[type].commands.splice(i, 1);
}
}
}
try{
if(rfc2136[type].commands.length > 0){
elevenLogJSON('info', {container:container.name, event:status, method:`nsupdate ${rfc2136[type].server}`});
await nsupdate(rfc2136[type].server, rfc2136[type].key, rfc2136[type].commands);
}else{
if(ENV_LABELS_RFC2136_ONLY_UPDATE_ON_CHANGE){
elevenLogJSON('info', {container:container.name, event:status, method:`nsupdate ${rfc2136[type].server} skipped due to same record data`});
}
}
}catch(e){
elevenLogJSON('error', e);
}
}
if(rfc2136.LAN.commands.length > 0 || rfc2136.WAN.commands.length){
await this.#rfc2136(rfc2136);
}
if(ENV_LABELS_WEBHOOK){
try{
await fetch(ENV_LABELS_WEBHOOK, {method:(
(update) ? 'PUT' : 'DELETE'
), body:JSON.stringify(container), headers:this.#webhook.headers, signal:AbortSignal.timeout(2500)});
}catch(e){
elevenLogJSON('error', {method:'fetch(webhook)', error:e});
}
if(this.#config?.webhook?.url){
await this.#webhook(container);
}
resolve(true);
}
});
})
resolve(true);
}));
}
async rfc2136KnownRecord(server, nsupdate){
async #rfc2136(rfc2136){
for(const type in rfc2136){
if(rfc2136[type].commands.length > 0 && rfc2136[type].server && rfc2136[type].key){
if(this.#config?.rfc2136?.['update-only'] || this.#defaults?.rfc2136?.['update-only']){
for(let i=0; i<rfc2136[type].commands.length; i++){
if(await this.#rfc2136knownRecord(rfc2136[type].server, rfc2136[type].commands[i])){
rfc2136[type].commands.splice(i, 1);
}
}
}
try{
if(rfc2136[type].commands.length > 0){
await nsupdate(rfc2136[type].server, rfc2136[type].key, rfc2136[type].commands);
}
}catch(e){
elevenLogJSON('error', JSON.stringify({nsupdate:{exception:e.toString()}}));
}
}
}
}
async #rfc2136knownRecord(server, nsupdate){
const matches = nsupdate.match(/update add (\S+) \d+ (\S+) (\S+)/i);
if(matches && matches.length >= 4){
try{
const record = await dig(server, matches[2], matches[1]);
const match = (
(record.match(new RegExp(matches[3], 'ig'))) ? true : false
);
elevenLogJSON('debug', {method:'rfc2136KnownRecord()', params:{server:server, nsupdate:nsupdate}, match:{A:record, B:matches[3], match:match}});
return(match);
return((record.match(new RegExp(matches[3], 'ig'))));
}catch(e){
elevenLogJSON('error', e);
return(false);
}
}
}
async #webhook(container){
try{
await fetch(this.#config.webhook.url, {method:(
(container.run) ? 'PUT' : 'DELETE'
), body:JSON.stringify(container), headers:this.#config.webhook.headers, signal:AbortSignal.timeout(2500)});
}catch(e){
elevenLogJSON('error', JSON.stringify({webhook:{exception:e.toString()}}));
}
}
}
new Labels().watch();