Compare commits
12
Commits
amd64-0.1.4
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7404380279 | ||
|
|
d0b98b201d | ||
|
|
33ac5c9e4c | ||
|
|
0cb3f5d165 | ||
|
|
b551e02001 | ||
|
|
fc94b61f62 | ||
|
|
426c69011b | ||
|
|
064fbaeac6 | ||
|
|
d978cd42c8 | ||
|
|
af8343f89a | ||
|
|
f60f0ad124 | ||
|
|
e7202481b3 |
+1
-2
@@ -1,2 +1 @@
|
||||
maintain/
|
||||
/build
|
||||
maintain/
|
||||
@@ -1,13 +1,14 @@
|
||||
# Alpine :: Traefik Labels
|
||||
    
|
||||
    
|
||||
|
||||
Run Traefik Labels based on Alpine Linux. Small, lightweight, secure and fast 🏔️
|
||||
|
||||
What can I do with this? Simply put: It will export any traefik labels on a container on the same host as this image runs to a Redis instance. This makes it possible for a centralized Traefik load balancer to update endpoints dynamically by utilizing the docker labels, just like you would on a local installation of Traefik with labels. It is meant as an alternative and simple way to proxy requests from a Traefik load balanacer to multiple docker nodes running in different networks and locations.
|
||||
## Description
|
||||
What can I do with this? Simply put: It will export any traefik labels on a container on the same host as this image runs to a Redis instance. This makes it possible for a centralized Traefik load balancer to update endpoints dynamically by utilizing the docker labels, just like you would on a local installation of Traefik with labels. It is meant as an alternative and simple way to proxy requests from a Traefik load balancer to multiple docker nodes running in different networks and locations. The image has the ability to use nsupdate with labels too, so that not only you can add a dynamic proxy to your Traefik, but add DNS entries to your internal or external NS servers. It uses split DNS as WAN and LAN in your labels, so you can update external as well as internal DNS entries on the fly. If a container is removed the image will automatically reverse any `nsupdate update add` to `nsupdate update delete` so entrie are removed too.
|
||||
|
||||
In order to use this image, you need to setup Traefik with a Redis provider and then point this image via REDIS_URL to the same Redis instance. Each entry will have an expire timer set in Redis, so that if a container is removed by a server crashing, Redis will automatically remove stale entries as well. Entries are refreshed every 60 seconds or on all docker container events (create, run, kill, stop, restart, ...).
|
||||
In order to use this image, you need to setup Traefik with a Redis provider and then point this image via REDIS_URL to the same Redis instance. Each entry will have an expire timer set in Redis, so that if a container is removed by a server crashing, Redis will automatically remove stale entries as well. Entries are refreshed every 300 seconds or on all docker container events (create, run, kill, stop, restart, ...). As for nsupdate, you need to setup tsig authentication in your NS servers and add the keys to the zones you want to be able to update, you can restrict the keys by using update-policy if you use BIND.
|
||||
|
||||
This image provides the ability to call a webhook for each container for each event or poll after the data was updates in Redis.
|
||||
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.
|
||||
|
||||
## Run
|
||||
```shell
|
||||
@@ -26,9 +27,9 @@ docker run --name traefik-labels \
|
||||
-d 11notes/traefik-labels:[tag]
|
||||
```
|
||||
|
||||
This is a demo webserver that will start on :8080, all the traefik labels will be exportet to the Redis instance. They follow the exact same [syntax](https://doc.traefik.io/traefik/routing/providers/kv/) as for normal Redis and Traefik, just as labels.
|
||||
## Examples
|
||||
```shell
|
||||
docker run --name demo \
|
||||
docker run --name traefik-demo \
|
||||
-p 8080:8080 \
|
||||
-l "traefik/http/routers/demo.domain.com/service=demo.domain.com" \
|
||||
-l "traefik/http/routers/demo.domain.com/rule=Host(`demo.domain.com`)" \
|
||||
@@ -38,6 +39,24 @@ docker run --name demo \
|
||||
-d 11notes/nginx:stable
|
||||
```
|
||||
|
||||
```shell
|
||||
docker run --name traefik-rfc2136-demo \
|
||||
-p 8080:8080 \
|
||||
-l "traefik/http/routers/demo.domain.com/service=demo.domain.com" \
|
||||
-l "traefik/http/routers/demo.domain.com/rule=Host(`demo.domain.com`)" \
|
||||
-l "traefik/http/routers/demo.domain.com/tls=true" \
|
||||
-l "traefik/http/routers/demo.domain.com/entrypoints=https" \
|
||||
-l "traefik/http/services/demo.domain.com/loadbalancer/servers/0/url=http://fqdn-of-docker-node:8080" \
|
||||
-l "rfc2136/WAN/server=ns.domain.com" \
|
||||
-l "rfc2136/WAN/key=algo:name:secret" \
|
||||
-l "rfc2136/WAN/nsupdate=update add foo.domain.com A 175.12.41.11" \
|
||||
-l "rfc2136/WAN/nsupdate=update add foo.domain.com TXT \"hello from traefik-labels!\"" \
|
||||
-l "rfc2136/LAN/server=ns.domain.local" \
|
||||
-l "rfc2136/LAN/key=algo:name:secret" \
|
||||
-l "rfc2136/LAN/nsupdate=update add foo.domain.local A 192.168.12.54" \
|
||||
-d 11notes/nginx:stable
|
||||
```
|
||||
|
||||
## Defaults
|
||||
| Parameter | Value | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -45,10 +64,12 @@ docker run --name demo \
|
||||
| `uid` | 1000 | user id 1000 |
|
||||
| `gid` | 1000 | group id 1000 |
|
||||
| `home` | /labels | home directory of user docker |
|
||||
| `api` | https://${IP}:5000 | HTTPS endpoint of Docker registry |
|
||||
|
||||
## Environment
|
||||
| Parameter | Value | Default |
|
||||
| --- | --- | --- |
|
||||
| `TZ` | [Time Zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) | null |
|
||||
| `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 |
|
||||
@@ -56,9 +77,9 @@ docker run --name demo \
|
||||
| `LABELS_WEBHOOK_AUTH_BASIC` | Basic authentication to use in the form of "username:password" for the webhook | |
|
||||
|
||||
## Parent image
|
||||
* [11notes/node:stable](https://github.com/11notes/docker-node)
|
||||
* [11notes/node:stable](https://hub.docker.com/r/11notes/node)
|
||||
|
||||
## Built with and thanks to
|
||||
## Built with (thanks to)
|
||||
* [npm::redis](https://www.npmjs.com/package/redis)
|
||||
* [npm::dockerode](https://www.npmjs.com/package/dockerode)
|
||||
* [nodejs](https://nodejs.org/en)
|
||||
|
||||
+13
-1
@@ -1,5 +1,14 @@
|
||||
# :: Util
|
||||
FROM alpine as util
|
||||
|
||||
RUN set -ex; \
|
||||
apk add --no-cache \
|
||||
git; \
|
||||
git clone https://github.com/11notes/util.git;
|
||||
|
||||
# :: Header
|
||||
FROM 11notes/node:stable
|
||||
COPY --from=util /util/node/util.js /labels/lib
|
||||
ENV APP_ROOT=/labels
|
||||
|
||||
# :: Run
|
||||
@@ -7,7 +16,10 @@
|
||||
|
||||
# :: prepare image
|
||||
RUN set -ex; \
|
||||
mkdir -p ${APP_ROOT};
|
||||
mkdir -p ${APP_ROOT}; \
|
||||
apk --no-cache add \
|
||||
bind-tools; \
|
||||
apk --no-cache upgrade;
|
||||
|
||||
# :: install
|
||||
RUN set -ex; \
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# :: Util
|
||||
FROM alpine as util
|
||||
|
||||
RUN set -ex; \
|
||||
apk add --no-cache \
|
||||
git; \
|
||||
git clone https://github.com/11notes/util.git;
|
||||
|
||||
# :: Builder
|
||||
FROM alpine AS qemu
|
||||
ENV QEMU_URL https://github.com/balena-io/qemu/releases/download/v3.0.0%2Bresin/qemu-3.0.0+resin-aarch64.tar.gz
|
||||
RUN apk add curl && curl -L ${QEMU_URL} | tar zxvf - -C . && mv qemu-3.0.0+resin-aarch64/qemu-aarch64-static .
|
||||
|
||||
# :: Header
|
||||
FROM arm64v8/node:20.10.0-alpine3.19
|
||||
COPY --from=util /util/node/util.js /labels/lib
|
||||
COPY --from=qemu qemu-aarch64-static /usr/bin
|
||||
ENV APP_ROOT=/labels
|
||||
|
||||
# :: Run
|
||||
USER root
|
||||
|
||||
# :: prepare image
|
||||
RUN set -ex; \
|
||||
mkdir -p ${APP_ROOT}; \
|
||||
apk --no-cache add \
|
||||
bind-tools; \
|
||||
apk --no-cache upgrade;
|
||||
|
||||
# :: install
|
||||
RUN set -ex; \
|
||||
cd ${APP_ROOT}; \
|
||||
npm --save install \
|
||||
redis@4.6.11 \
|
||||
dockerode@4.0.0;
|
||||
|
||||
# :: update image
|
||||
RUN set -ex; \
|
||||
apk --no-cache upgrade;
|
||||
|
||||
# :: copy root filesystem changes and set correct permissions
|
||||
COPY ./rootfs /
|
||||
RUN set -ex; \
|
||||
chmod +x -R /usr/local/bin; \
|
||||
chown -R 1000:1000 \
|
||||
${APP_ROOT};
|
||||
|
||||
# :: Start
|
||||
USER docker
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
docker run --rm --privileged multiarch/qemu-user-static:register --reset
|
||||
@@ -1,137 +0,0 @@
|
||||
const Docker = require('dockerode');
|
||||
const redis = require('redis');
|
||||
const ERROR = 1;
|
||||
|
||||
class Labels{
|
||||
#docker;
|
||||
#redis;
|
||||
#poll = false;
|
||||
|
||||
constructor(){
|
||||
this.#docker = new Docker({socketPath:'/run/docker.sock'});
|
||||
}
|
||||
|
||||
#log(message, error){
|
||||
console.log(JSON.stringify({
|
||||
time:new Date().toISOString(),
|
||||
type:(error === ERROR) ? 'ERROR' : 'INFO',
|
||||
message:message,
|
||||
}));
|
||||
}
|
||||
|
||||
async watch(){
|
||||
if('' !== process.env.LABELS_WEBHOOK){
|
||||
this.#log(`using webhook ${process.env.LABELS_WEBHOOK}`);
|
||||
}
|
||||
|
||||
this.#redis = await redis.createClient({
|
||||
url:process.env.LABELS_REDIS_URL,
|
||||
pingInterval:30000,
|
||||
socket:{
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
});
|
||||
|
||||
this.#redis.connect();
|
||||
this.#redis.on('ready', ()=>{
|
||||
this.#log('successfully connected to redis database');
|
||||
(async() => {
|
||||
await this.dockerPoll();
|
||||
})();
|
||||
this.dockerEvents();
|
||||
});
|
||||
|
||||
this.#redis.on('error', error =>{
|
||||
this.#log(error, ERROR);
|
||||
});
|
||||
|
||||
setInterval(async() => {
|
||||
await this.dockerPoll();
|
||||
}, parseInt(process.env.LABELS_INTERVAL)*1000);
|
||||
}
|
||||
|
||||
dockerEvents(){
|
||||
this.#docker.getEvents({}, (error, data) => {
|
||||
if(error){
|
||||
this.#log(error, ERROR);
|
||||
}else{
|
||||
data.on('data', async(chunk) => {
|
||||
const event = JSON.parse(chunk.toString('utf8'));
|
||||
if(/Container/i.test(event?.Type) && /^(start|stop|restart|kill|die|destroy)$/i.test(event?.status)){
|
||||
await this.dockerInspect(event.id, event.status);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async dockerPoll(){
|
||||
if(!this.#poll){
|
||||
try{
|
||||
this.#poll = true;
|
||||
this.#log(`poll start (interval:${process.env.LABELS_INTERVAL})}`);
|
||||
this.#docker.listContainers((error, containers) => {
|
||||
containers.forEach(async(container) => {
|
||||
await this.dockerInspect(container.Id);
|
||||
});
|
||||
});
|
||||
}catch(e){
|
||||
this.#log(e, ERROR);
|
||||
}finally{
|
||||
this.#log('poll end');
|
||||
this.#poll = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dockerInspect(id, status = null){
|
||||
return(new Promise((resolve, reject) => {
|
||||
const container = this.#docker.getContainer(id);
|
||||
container.inspect(async(error, data) => {
|
||||
|
||||
let update = false;
|
||||
const webHook = {event:status, labels:{}};
|
||||
const headers = {'Content-Type':'application/json'};
|
||||
|
||||
if('' !== process.env.LABELS_WEBHOOK_AUTH_BASIC){
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(process.env.LABELS_WEBHOOK_AUTH_BASIC).toString('base64')
|
||||
}
|
||||
|
||||
if(error){
|
||||
reject(error);
|
||||
}
|
||||
|
||||
this.#log(`inspect container ${data.Name.replace(/^\//i, '')}${(
|
||||
(null === status) ? '' : ` event[${status}]`
|
||||
)}`);
|
||||
|
||||
if(/start|restart/i.test(status)){
|
||||
update = true;
|
||||
}
|
||||
|
||||
for(const label in data?.Config?.Labels){
|
||||
if(/traefik\//i.test(label)){
|
||||
webHook.labels[label] = data?.Config?.Labels[label];
|
||||
if(update){
|
||||
await this.#redis.set(label, data?.Config?.Labels[label], {EX:parseInt(process.env.LABELS_INTERVAL) + parseInt(process.env.LABELS_TIMEOUT)});
|
||||
}else{
|
||||
await this.#redis.del(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
if('' !== process.env.LABELS_WEBHOOK){
|
||||
try{
|
||||
const webHookCall = await fetch(process.env.LABELS_WEBHOOK, {method:(
|
||||
(update) ? 'PUT' : 'DELETE'
|
||||
), body:JSON.stringify(webHook), headers:headers});
|
||||
}catch(e){
|
||||
this.#log(e, ERROR);
|
||||
}
|
||||
}
|
||||
resolve(true);
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
new Labels().watch();
|
||||
+170
-17
@@ -1,20 +1,173 @@
|
||||
process.once('SIGTERM', () => process.exit(0));
|
||||
process.once('SIGINT', () => process.exit(0));
|
||||
const { fork } = require('node:child_process');
|
||||
const child = fork(`${__dirname}/app.js`, [], {
|
||||
env:{
|
||||
LABELS_REDIS_URL:process.env?.LABELS_REDIS_URL || 'rediss://localhost:6379/0',
|
||||
LABELS_INTERVAL:parseInt(process.env?.LABELS_INTERVAL || 300),
|
||||
LABELS_TIMEOUT:parseInt(process.env?.LABELS_TIMEOUT|| 30),
|
||||
LABELS_WEBHOOK:process.env?.LABELS_WEBHOOK || '',
|
||||
LABELS_WEBHOOK_AUTH_BASIC:process.env?.LABELS_WEBHOOK_AUTH_BASIC || '',
|
||||
|
||||
const Docker = require('dockerode');
|
||||
const redis = require('redis');
|
||||
const { nsupdate } = requore('./nsupdate');
|
||||
const { logJSON } = 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;
|
||||
|
||||
class Labels{
|
||||
#docker;
|
||||
#redis;
|
||||
#poll = false;
|
||||
#webhook = {
|
||||
headers:{'Content-Type':'application/json'}
|
||||
};
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
});
|
||||
child.on('error', (error) =>{
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
child.on('close', (code) =>{
|
||||
console.warn(`child process closed with exit code ${code}`);
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
async watch(){
|
||||
if(ENV_LABELS_WEBHOOK){
|
||||
logJSON('info', `using webhook ${ENV_LABELS_WEBHOOK}`);
|
||||
}
|
||||
|
||||
this.#redis = await redis.createClient({
|
||||
url:process.env.LABELS_REDIS_URL,
|
||||
pingInterval:30000,
|
||||
socket:{
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
});
|
||||
|
||||
this.#redis.connect();
|
||||
this.#redis.on('ready', ()=>{
|
||||
logJSON('info', 'successfully connected to redis');
|
||||
(async() => {
|
||||
await this.dockerPoll();
|
||||
})();
|
||||
this.dockerEvents();
|
||||
});
|
||||
|
||||
this.#redis.on('error', error =>{
|
||||
logJSON('error', error);
|
||||
});
|
||||
|
||||
setInterval(async() => {
|
||||
await this.dockerPoll();
|
||||
}, ENV_REDIS_INTERVAL*1000);
|
||||
}
|
||||
|
||||
dockerEvents(){
|
||||
this.#docker.getEvents({}, (error, data) => {
|
||||
if(error){
|
||||
logJSON('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 dockerPoll(){
|
||||
if(!this.#poll){
|
||||
try{
|
||||
this.#poll = true;
|
||||
this.#docker.listContainers((error, containers) => {
|
||||
if(!error){
|
||||
containers.forEach(async(container) => {
|
||||
await this.dockerInspect(container.Id, 'poll');
|
||||
});
|
||||
}
|
||||
});
|
||||
}catch(e){
|
||||
logJSON('error', e);
|
||||
}finally{
|
||||
this.#poll = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dockerInspect(id, status = null){
|
||||
return(new Promise((resolve, reject) => {
|
||||
const container = this.#docker.getContainer(id);
|
||||
container.inspect(async(error, data) => {
|
||||
if(!error){
|
||||
const update = (/start|poll/i.test(status)) ? true : false;
|
||||
const container = {
|
||||
event:status,
|
||||
labels:{
|
||||
traefik:[],
|
||||
rfc2136:[],
|
||||
},
|
||||
};
|
||||
|
||||
const rfc2136 = {
|
||||
WAN:{server:'', key:'', commands:[]},
|
||||
LAN:{server:'', key:'', commands:[]},
|
||||
}
|
||||
|
||||
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});
|
||||
}else{
|
||||
await this.#redis.del(label);
|
||||
}
|
||||
container.labels.traefik[label] = data?.Config?.Labels[label];
|
||||
break;
|
||||
|
||||
case /rfc2136\//i.test(label):
|
||||
container.labels.rfc2136[label] = data?.Config?.Labels[label];
|
||||
const type = ((label.match(/rfc2136\/WAN\//i)) ? 'WAN' : 'LAN');
|
||||
switch(true){
|
||||
case /rfc2136\/\S+\/server/i.test(label):
|
||||
rfc2136[type].server = data?.Config?.Labels[label];
|
||||
break;
|
||||
|
||||
case /rfc2136\/\S+\/key/i.test(label):
|
||||
rfc2136[type].key = data?.Config?.Labels[label];
|
||||
break;
|
||||
|
||||
default:
|
||||
if(!update){
|
||||
data?.Config?.Labels[label] = data?.Config?.Labels[label].replace(/update add/i, 'update delete');
|
||||
}
|
||||
rfc2136[type].commands.push(data?.Config?.Labels[label]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for(const type in rfc2136){
|
||||
if(rfc2136[type].commands.length > 0 && rfc2136[type].server && rfc2136[type].key){
|
||||
try{
|
||||
await nsupdate(rfc2136[type].server, rfc2136[type].key, rfc2136[type].commands);
|
||||
}catch(e){
|
||||
logJSON('error', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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){
|
||||
logJSON('error', e);
|
||||
}
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
new Labels().watch();
|
||||
@@ -0,0 +1,27 @@
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
exports.nsupdate = async(server, key, commands) => {
|
||||
commands.unshift(`server ${server}`);
|
||||
commands.push('send');
|
||||
return(new Promise((resolve, reject) => {
|
||||
const nsupdate = spawn('/usr/bin/nsupdate', ['-y', key]);
|
||||
let errors = '';
|
||||
nsupdate.stderr.on('data', data =>{errors += data.toString()});
|
||||
nsupdate.on('error', error => {reject(error)});
|
||||
nsupdate.on('exit', code =>{
|
||||
if(code === 0){
|
||||
if(errors.length > 0){
|
||||
reject(errors);
|
||||
}else{
|
||||
resolve(true);
|
||||
}
|
||||
}else{
|
||||
reject(errors);
|
||||
}
|
||||
});
|
||||
for(const command of commands){
|
||||
nsupdate.stdin.write(`${command}\n`);
|
||||
}
|
||||
nsupdate.stdin.end();
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user