├── src
├── TraefikKobling.slnx
└── TraefikKobling.Worker
│ ├── appsettings.json
│ ├── Traefik
│ ├── Service.cs
│ ├── TcpRouter.cs
│ ├── Middleware.cs
│ ├── HttpRouter.cs
│ └── GeneratedJsonInfo.cs
│ ├── appsettings.yml
│ ├── Extensions
│ ├── EnumerableExtensions.cs
│ ├── StringExtensions.cs
│ ├── StringBuilderExtensions.cs
│ ├── HttpClientExtensions.cs
│ ├── RedisExtensions.cs
│ ├── DictionaryExtensions.cs
│ └── ConfigurationExtensions.cs
│ ├── Exporters
│ ├── ITraefikExporter.cs
│ ├── RedisTraefikExporter.cs
│ └── FileTraefikExporter.cs
│ ├── Configuration
│ ├── KoblingOptions.cs
│ └── Server.cs
│ ├── Properties
│ └── launchSettings.json
│ ├── TraefikKobling.Worker.csproj
│ ├── Program.cs
│ └── Worker.cs
├── .dockeringore
├── docker
├── .dockerignore
├── docker-compose.yml
└── Dockerfile
├── LICENSE.md
├── CHANGELOG.md
├── .github
└── workflows
│ └── docker-main.yml
├── .gitignore
└── README.md
/src/TraefikKobling.slnx:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Error",
5 | "Microsoft.Hosting.Lifetime": "Information"
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Traefik/Service.cs:
--------------------------------------------------------------------------------
1 | namespace TraefikKobling.Worker.Traefik;
2 |
3 | public class Service
4 | {
5 | public required string Name { get; set; }
6 | public required string Provider { get; set; }
7 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/appsettings.yml:
--------------------------------------------------------------------------------
1 | servers:
2 | - name: "compute-2"
3 | apiAddress: http://192.168.0.14
4 | apiHost: traefik
5 | destinationAddress: http://192.168.0.14
6 | entryPoints:
7 | web: http
8 | web-secure: http
9 |
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Traefik/TcpRouter.cs:
--------------------------------------------------------------------------------
1 | namespace TraefikKobling.Worker.Traefik;
2 |
3 | public class TcpRouter
4 | {
5 | public string[] EntryPoints { get; set; } = [];
6 | public string Service { get; set; } = "";
7 | public string Rule { get; set; } = "";
8 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Extensions/EnumerableExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace TraefikKobling.Worker.Extensions;
2 |
3 | public static class EnumerableExtensions
4 | {
5 | public static bool IsEmpty(this IEnumerable source)
6 | {
7 | return !source.Any();
8 | }
9 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Extensions/StringExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace TraefikKobling.Worker.Extensions;
2 |
3 | public static class StringExtensions
4 | {
5 | public static bool IsNullOrWhiteSpace(this string? value)
6 | {
7 | return string.IsNullOrWhiteSpace(value);
8 | }
9 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Exporters/ITraefikExporter.cs:
--------------------------------------------------------------------------------
1 | namespace TraefikKobling.Worker.Exporters;
2 |
3 | public interface ITraefikExporter
4 | {
5 | Task ExportTraefikEntries(IDictionary oldEntries, IDictionary newEntries, CancellationToken cancellationToken);
6 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Traefik/Middleware.cs:
--------------------------------------------------------------------------------
1 | namespace TraefikKobling.Worker.Traefik;
2 |
3 | public class Middleware
4 | {
5 | public required string Status { get; set; }
6 | public string[] UsedBy { get; set; } = [];
7 | public required string Name { get; set; }
8 | public required string Provider { get; set; }
9 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Configuration/KoblingOptions.cs:
--------------------------------------------------------------------------------
1 | namespace TraefikKobling.Worker.Configuration;
2 |
3 | public class KoblingOptions
4 | {
5 | public required Server[] Servers { get; set; }
6 | public int RunEvery { get; set; }
7 | public bool? ForwardMiddlewares { get; set; }
8 | public bool? ForwardServices { get; set; }
9 | }
10 |
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Traefik/HttpRouter.cs:
--------------------------------------------------------------------------------
1 | namespace TraefikKobling.Worker.Traefik;
2 |
3 | public class HttpRouter
4 | {
5 | public string[] EntryPoints { get; set; } = [];
6 | public string[] Middlewares { get; set; } = [];
7 | public string Service { get; set; } = "";
8 | public string Rule { get; set; } = "";
9 | public string Name { get; set; } = "";
10 | public long Priority { get; set; }
11 | }
--------------------------------------------------------------------------------
/.dockeringore:
--------------------------------------------------------------------------------
1 | **/.dockerignore
2 | **/.env
3 | **/.git
4 | **/.gitignore
5 | **/.project
6 | **/.settings
7 | **/.toolstarget
8 | **/.vs
9 | **/.vscode
10 | **/.idea
11 | **/*.*proj.user
12 | **/*.dbmdl
13 | **/*.jfm
14 | **/azds.yaml
15 | **/bin
16 | **/charts
17 | **/docker-compose*
18 | **/Dockerfile*
19 | **/node_modules
20 | **/npm-debug.log
21 | **/obj
22 | **/secrets.dev.yaml
23 | **/values.dev.yaml
24 | LICENSE
25 | README.md
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Extensions/StringBuilderExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Text;
2 |
3 | namespace TraefikKobling.Worker.Extensions;
4 |
5 | public static class StringBuilderExtensions
6 | {
7 | public static StringBuilder Indent(this StringBuilder builder, int level, char character = '\t')
8 | {
9 | for (int i = 0; i < level; i++)
10 | {
11 | builder.Append(character);
12 | }
13 | return builder;
14 | }
15 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Traefik/GeneratedJsonInfo.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json;
2 | using System.Text.Json.Serialization;
3 |
4 | namespace TraefikKobling.Worker.Traefik;
5 |
6 | [JsonSerializable(typeof(HttpRouter[]))]
7 | [JsonSerializable(typeof(TcpRouter[]))]
8 | [JsonSerializable(typeof(Middleware[]))]
9 | [JsonSerializable(typeof(Service[]))]
10 | [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
11 | public partial class GeneratedJsonInfo : JsonSerializerContext;
--------------------------------------------------------------------------------
/docker/.dockerignore:
--------------------------------------------------------------------------------
1 | # Git
2 | .git/
3 | .gitignore
4 |
5 | # Documentation
6 | *.md
7 | LICENSE*
8 |
9 | # Build artifacts
10 | **/bin/
11 | **/obj/
12 | **/out/
13 |
14 | # IDE files
15 | .vs/
16 | .vscode/
17 | **/.idea/
18 | *.user
19 | *.suo
20 | *.sln.docstates
21 |
22 | # OS files
23 | .DS_Store
24 | Thumbs.db
25 |
26 | # Docker
27 | Dockerfile*
28 | docker-compose*
29 | .dockerignore
30 |
31 | # CI/CD
32 | .github/
33 |
34 | # Runtime files
35 | *.log
36 | appsettings.Development.json
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "profiles": {
3 | "TraefikKobling.Worker": {
4 | "commandName": "Project",
5 | "dotnetRunMessages": true,
6 | "environmentVariables": {
7 | "DOTNET_ENVIRONMENT": "Development",
8 | "CONFIG_PATH": "./appsettings.yml",
9 | "RUN_EVERY": "5",
10 | "TRAEFIK_DYNAMIC_CONFIG_PATH": "./dynamic_kobling.yml"
11 | }
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Configuration/Server.cs:
--------------------------------------------------------------------------------
1 | namespace TraefikKobling.Worker.Configuration;
2 |
3 | public class Server
4 | {
5 | public required string Name { get; set; }
6 | public required Uri ApiAddress { get; set; }
7 | public string? ApiHost { get; set; }
8 | public required Uri DestinationAddress { get; set; }
9 | public bool? ForwardMiddlewares { get; set; }
10 | public bool? ForwardServices { get; set; }
11 |
12 | public Dictionary EntryPoints { get; set; } = new()
13 | {
14 | {"http", "http"}
15 | };
16 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Extensions/HttpClientExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Http.Headers;
2 | using System.Text;
3 | using TraefikKobling.Worker.Configuration;
4 |
5 | namespace TraefikKobling.Worker.Extensions;
6 |
7 | public static class HttpClientExtensions
8 | {
9 | public static void SetUpHttpClient(this HttpClient httpClient, Server server)
10 | {
11 | httpClient.BaseAddress = server.ApiAddress;
12 | if (!server.ApiHost.IsNullOrWhiteSpace())
13 | httpClient.DefaultRequestHeaders.Host = server.ApiHost;
14 |
15 | if (!server.ApiAddress.UserInfo.IsNullOrWhiteSpace())
16 | httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(server.ApiAddress.UserInfo)));
17 |
18 | }
19 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Extensions/RedisExtensions.cs:
--------------------------------------------------------------------------------
1 | using StackExchange.Redis;
2 |
3 | namespace TraefikKobling.Worker.Extensions;
4 |
5 | internal static class RedisExtensions
6 | {
7 | public static async Task StringUpdateIfChanged(this IDatabase database, string key, string value)
8 | {
9 | var currentValue = await database.StringGetAsync(key);
10 |
11 | if (!currentValue.HasValue || currentValue != value)
12 | {
13 | await database.StringSetAsync(key, value, when: When.Always);
14 | }
15 | }
16 |
17 | public static void FlushDatabase(this ConnectionMultiplexer redis, string connectionString)
18 | {
19 | var server = redis.GetServer(connectionString);
20 | var database = redis.GetDatabase();
21 | foreach (var key in server.Keys())
22 | {
23 | database.KeyDelete(key);
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/docker/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | networks:
4 | default:
5 | name: "web"
6 |
7 | services:
8 | traefik:
9 | image: traefik:latest
10 | container_name: traefik
11 | restart: unless-stopped
12 | ports:
13 | - 80:80
14 | - 8080:8080
15 | depends_on:
16 | - redis
17 | volumes:
18 | - ./traefik.yml:/traefik.yml:ro
19 | labels:
20 | traefik.enable: "true"
21 | traefik.http.routers.traefik.rule: "Host(`traefik.domain.tld`)"
22 | traefik.http.routers.traefik.service: "api@internal"
23 | traefik.http.services.traefik.loadbalancer.server.port: "8080"
24 |
25 | traefik-kobling:
26 | image: alpine-variant
27 | container_name: traefik-kobling
28 | depends_on:
29 | - redis
30 | volumes:
31 | - ./config.yml:/config.yml
32 | environment:
33 | REDIS_URL: "redis:6379"
34 | RUN_EVERY: 20
35 |
36 | redis:
37 | image: redis:alpine
38 | container_name: redis
39 | restart: unless-stopped
40 |
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Exporters/RedisTraefikExporter.cs:
--------------------------------------------------------------------------------
1 | using StackExchange.Redis;
2 | using TraefikKobling.Worker.Extensions;
3 |
4 | namespace TraefikKobling.Worker.Exporters;
5 |
6 | public class RedisTraefikExporter : ITraefikExporter
7 | {
8 | private readonly IConnectionMultiplexer _redis;
9 |
10 | public RedisTraefikExporter(ILogger logger, IConnectionMultiplexer redis)
11 | {
12 | _redis = redis;
13 | logger.LogInformation("Exporting Traefik configuration to redis");
14 | }
15 |
16 | public async Task ExportTraefikEntries(IDictionary oldEntries, IDictionary newEntries, CancellationToken cancellationToken)
17 | {
18 | var entriesToRemove = oldEntries.Keys.Except(newEntries.Keys);
19 |
20 | var db = _redis.GetDatabase();
21 |
22 | foreach (var key in entriesToRemove)
23 | {
24 | await db.KeyDeleteAsync(key);
25 | }
26 |
27 | foreach (var (key, value) in newEntries)
28 | {
29 | await db.StringUpdateIfChanged(key, value);
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Lucas Dell'Isola
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Extensions/DictionaryExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace TraefikKobling.Worker.Extensions;
2 |
3 | public static class DictionaryExtensions
4 | {
5 | public static void Merge(this T me, IDictionary other)
6 | where T : IDictionary
7 | {
8 | foreach (var (key, value) in other)
9 | {
10 | me[key] = value;
11 | }
12 | }
13 |
14 | public static bool SetEquals(this IDictionary dictionary, IDictionary other)
15 | {
16 | var comparer = new KeyValueComparer();
17 | return dictionary.ToHashSet(comparer).SetEquals(other.ToHashSet(comparer));
18 | }
19 |
20 | private class KeyValueComparer : IEqualityComparer>
21 | {
22 | private static readonly StringComparer Comparer = StringComparer.Ordinal;
23 | public bool Equals(KeyValuePair x, KeyValuePair y)
24 | {
25 | return Comparer.Equals(x.Key, y.Key) && Comparer.Equals(x.Value, y.Value);
26 | }
27 |
28 | public int GetHashCode(KeyValuePair obj) => obj.Key.GetHashCode() ^ obj.Value.GetHashCode();
29 | }
30 | }
--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build-env
2 | WORKDIR /src
3 |
4 | # Install AOT prerequisites
5 | RUN apk add --no-cache clang build-base zlib-dev
6 |
7 | # Copy everything
8 | COPY ./src ./
9 |
10 | # Set target architecture based on build platform
11 | ARG TARGETARCH
12 | RUN case ${TARGETARCH} in \
13 | "amd64") echo "linux-musl-x64" > /tmp/rid ;; \
14 | "arm64") echo "linux-musl-arm64" > /tmp/rid ;; \
15 | *) echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \
16 | esac
17 |
18 | # Restore as distinct layers
19 | RUN dotnet restore /src/TraefikKobling.Worker/TraefikKobling.Worker.csproj -r $(cat /tmp/rid)
20 |
21 | # Build and publish a release
22 | RUN dotnet publish /src/TraefikKobling.Worker/TraefikKobling.Worker.csproj \
23 | -c Release \
24 | -o out \
25 | --no-restore \
26 | -r $(cat /tmp/rid)
27 |
28 | # Build runtime image - minimal Alpine
29 | FROM alpine:3.21 AS runtime
30 | RUN apk add --no-cache ca-certificates tzdata && rm -rf /var/cache/apk/*
31 |
32 | RUN adduser -D -s /bin/sh appuser
33 | USER appuser
34 |
35 | ENV REDIS_URL=redis:6379
36 | ENV TRAEFIK_EXPORTER=redis
37 | WORKDIR /app
38 | COPY --from=build-env /src/out .
39 | RUN mkdir ./config
40 | ENTRYPOINT ["./TraefikKobling.Worker"]
41 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changes
2 |
3 | ## v0.4.0
4 | **Released on**: 13/09/2025
5 | ### Changes
6 | - Add support for priority in HTTP routes
7 |
8 | ## v0.3.0
9 | **Released on**: 06/09/2025
10 | ### Changes
11 | - Reduced docker image size in half.
12 | - Added support for file-based configuration next to redis.
13 | ## v0.2.2
14 | **Released on**: 13/08/2025
15 | ### Changes:
16 | - Fixed error where route registration would stop competely if one service did not have an endpoint registered
17 |
18 | ## v0.2.1
19 | **Released on**: 08/07/2025
20 | ### Changes
21 | - Fix for handling multiple middlewares.
22 |
23 | ## v.0.2.0
24 | **Released on**: 29/05/2025
25 | ### Changes
26 | - Added support to forward sevices and middlewares to support forward authentication
27 |
28 | ## v0.1.2
29 | **Released on**: 25/04/2023
30 | ### Changes
31 | - Fixed bug that would register services without any entry point
32 |
33 | See full changelog [here](https://github.com/ldellisola/TraefikKobling/releases/tag/v0.1.2).
34 |
35 | ## v0.1.1
36 | **Released on**: 25/04/2023
37 | ### Changes
38 | - Update documentation
39 | - Improved configuration parsing
40 | - It cleans the redis db on start.
41 | - Added support for connecting to Traefik API's with Basic Authentication.
42 | - Added support for passing a hostname to connect to the Traefik API.
43 |
44 | See full changelog [here](https://github.com/ldellisola/TraefikKobling/releases/tag/v0.1.1).
45 | ## v0.1.0
46 | **Released on**: 23/04/2023
47 | ### Changes
48 | - Added TCP router support.
49 | - Made entry point names dynamic.
50 |
51 | See full changelog [here](https://github.com/ldellisola/TraefikKobling/releases/tag/v0.1.0).
52 | ## v0.0.1
53 | **Released on**: 22/04/2023
54 | ### Changes
55 | - Created project.
56 | - Fixed error with repeated dictionary entries.
57 |
58 | See full changelog [here](https://github.com/ldellisola/TraefikKobling/releases/tag/v0.0.1).
59 |
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/TraefikKobling.Worker.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0
5 | enable
6 | enable
7 | dotnet-TraefikCompanion.Worker-496a9f6d-6cbc-4100-a3a8-208e5e3b5413
8 | Linux
9 |
10 | true
11 | true
12 | true
13 | true
14 |
15 |
16 | true
17 | link
18 | Size
19 | true
20 | true
21 | false
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | PreserveNewest
34 |
35 |
36 |
37 |
38 |
39 | Dockerfile
40 |
41 |
42 | .dockerignore
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Extensions/ConfigurationExtensions.cs:
--------------------------------------------------------------------------------
1 | using TraefikKobling.Worker.Configuration;
2 |
3 | namespace TraefikKobling.Worker.Extensions;
4 |
5 | public static class ConfigurationExtensions
6 | {
7 | public static KoblingOptions AddKoblingOptions(this IServiceCollection services, IConfiguration configuration)
8 | {
9 | var servers = new List();
10 | foreach (var child in configuration.GetSection("servers").GetChildren())
11 | {
12 | var server = new Server
13 | {
14 | Name = child.GetValue(nameof(Server.Name)) ?? throw new ArgumentException($"{nameof(Server.Name)} is required"),
15 | ApiAddress = new Uri(child.GetValue(nameof(Server.ApiAddress)) ?? throw new ArgumentException($"{nameof(Server.ApiAddress)} is required")),
16 | DestinationAddress = new Uri(child.GetValue(nameof(Server.DestinationAddress))?? throw new ArgumentException($"{nameof(Server.DestinationAddress)} is required")),
17 | ApiHost = child.GetValue(nameof(Server.ApiHost)),
18 | ForwardMiddlewares = child.GetValue(nameof(Server.ForwardMiddlewares)),
19 | ForwardServices = child.GetValue(nameof(Server.ForwardServices))
20 | };
21 |
22 | if (child.GetSection("entryPoints").Get>() is { Count: > 0 } entryPoints)
23 | server.EntryPoints = entryPoints;
24 |
25 | servers.Add(server);
26 | }
27 |
28 | var options = new KoblingOptions
29 | {
30 | Servers = servers.ToArray(),
31 | RunEvery = configuration.GetValue("RUN_EVERY",60),
32 | ForwardMiddlewares = configuration.GetValue(nameof(KoblingOptions.ForwardMiddlewares)),
33 | ForwardServices = configuration.GetValue(nameof(KoblingOptions.ForwardMiddlewares))
34 | };
35 |
36 | services.AddSingleton(options);
37 | return options;
38 | }
39 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Program.cs:
--------------------------------------------------------------------------------
1 | using StackExchange.Redis;
2 | using TraefikKobling.Worker;
3 | using TraefikKobling.Worker.Exporters;
4 | using TraefikKobling.Worker.Extensions;
5 |
6 |
7 | IHost host = Host.CreateDefaultBuilder(args)
8 | .ConfigureAppConfiguration(t =>
9 | {
10 | var file = Environment.GetEnvironmentVariable("CONFIG_PATH") ?? "/config.yml";
11 | t.AddYamlFile(file, optional: false);
12 | })
13 | .ConfigureServices((builder,services) =>
14 | {
15 | var options = services.AddKoblingOptions(builder.Configuration);
16 |
17 | if (options.Servers.IsEmpty())
18 | throw new ArgumentException("No servers configured.");
19 |
20 | foreach (var server in options.Servers)
21 | {
22 | services.AddHttpClient(server.Name,t => t.SetUpHttpClient(server));
23 | }
24 |
25 | var exporter = builder.Configuration.GetValue("TRAEFIK_EXPORTER");
26 |
27 | switch (exporter?.ToLowerInvariant())
28 | {
29 | case "redis":
30 | var redisConnectionString = builder.Configuration.GetValue("REDIS_URL") ?? throw new ArgumentException("REDIS_URL is required.");
31 | var redisConnection = ConnectionMultiplexer.Connect(redisConnectionString);
32 | redisConnection.GetServers().First().Ping();
33 | redisConnection.FlushDatabase(redisConnectionString);
34 | services.AddSingleton(redisConnection);
35 | services.AddSingleton();
36 | break;
37 | case "file":
38 | var dynamicFilePath = builder.Configuration.GetValue("TRAEFIK_DYNAMIC_CONFIG_PATH")
39 | ?? "/dynamic-kobling.yml";
40 | services.AddSingleton(t => new(
41 | t.GetRequiredService>(),
42 | dynamicFilePath
43 | )
44 | );
45 | break;
46 | default:
47 | throw new NotSupportedException($"Unknown exporter '{exporter}'.");
48 | }
49 |
50 | services.AddHostedService();
51 | })
52 | .Build();
53 |
54 | host.Run();
55 |
--------------------------------------------------------------------------------
/.github/workflows/docker-main.yml:
--------------------------------------------------------------------------------
1 | name: Docker
2 | on:
3 | push:
4 | tags: ["v*.*.*"]
5 | pull_request:
6 | branches: ["main"]
7 | workflow_dispatch:
8 |
9 | env:
10 | REGISTRY: ghcr.io
11 | IMAGE_NAME: ldellisola/traefik-kobling
12 |
13 | jobs:
14 | build:
15 | runs-on: ${{ matrix.runner }}
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | include:
20 | - platform: linux/amd64
21 | runner: ubuntu-latest
22 | arch: amd64
23 | - platform: linux/arm64
24 | runner: ubuntu-24.04-arm
25 | arch: arm64
26 | permissions:
27 | contents: read
28 | packages: write
29 | id-token: write
30 | steps:
31 | - name: Checkout repository
32 | uses: actions/checkout@v4
33 |
34 | - name: Setup Docker buildx
35 | uses: docker/setup-buildx-action@v3
36 |
37 | - name: Log into registry ${{ env.REGISTRY }}
38 | if: github.event_name != 'pull_request'
39 | uses: docker/login-action@v3
40 | with:
41 | registry: ${{ env.REGISTRY }}
42 | username: ${{ github.actor }}
43 | password: ${{ secrets.GITHUB_TOKEN }}
44 |
45 | - name: Extract Docker metadata
46 | id: meta
47 | uses: docker/metadata-action@v5
48 | with:
49 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
50 | tags: |
51 | type=schedule
52 | type=ref,event=branch
53 | type=ref,event=pr
54 | type=semver,pattern={{version}}
55 | type=semver,pattern={{major}}.{{minor}}
56 | type=semver,pattern={{major}}
57 | type=sha
58 |
59 | - name: Build and push by digest
60 | id: build
61 | uses: docker/build-push-action@v5
62 | with:
63 | context: .
64 | platforms: ${{ matrix.platform }}
65 | file: docker/Dockerfile
66 | labels: ${{ steps.meta.outputs.labels }}
67 | outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
68 |
69 | - name: Export digest
70 | if: github.event_name != 'pull_request'
71 | run: |
72 | mkdir -p /tmp/digests
73 | digest="${{ steps.build.outputs.digest }}"
74 | touch "/tmp/digests/${digest#sha256:}"
75 |
76 | - name: Upload digest
77 | if: github.event_name != 'pull_request'
78 | uses: actions/upload-artifact@v4
79 | with:
80 | name: digests-${{ matrix.arch }}
81 | path: /tmp/digests/*
82 | if-no-files-found: error
83 | retention-days: 1
84 |
85 | merge:
86 | runs-on: ubuntu-latest
87 | needs: build
88 | if: github.event_name != 'pull_request'
89 | permissions:
90 | contents: read
91 | packages: write
92 | id-token: write
93 | steps:
94 | - name: Download digests
95 | uses: actions/download-artifact@v4
96 | with:
97 | path: /tmp/digests
98 | pattern: digests-*
99 | merge-multiple: true
100 |
101 | - name: Setup Docker buildx
102 | uses: docker/setup-buildx-action@v3
103 |
104 | - name: Log into registry ${{ env.REGISTRY }}
105 | uses: docker/login-action@v3
106 | with:
107 | registry: ${{ env.REGISTRY }}
108 | username: ${{ github.actor }}
109 | password: ${{ secrets.GITHUB_TOKEN }}
110 |
111 | - name: Extract Docker metadata
112 | id: meta
113 | uses: docker/metadata-action@v5
114 | with:
115 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
116 | tags: |
117 | type=schedule
118 | type=ref,event=branch
119 | type=ref,event=pr
120 | type=semver,pattern={{version}}
121 | type=semver,pattern={{major}}.{{minor}}
122 | type=semver,pattern={{major}}
123 | type=sha
124 |
125 | - name: Create manifest list and push
126 | working-directory: /tmp/digests
127 | run: |
128 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
129 | $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
130 |
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Exporters/FileTraefikExporter.cs:
--------------------------------------------------------------------------------
1 | using System.Text;
2 | using TraefikKobling.Worker.Extensions;
3 |
4 | namespace TraefikKobling.Worker.Exporters;
5 |
6 | public class FileTraefikExporter : ITraefikExporter
7 | {
8 | private readonly string _dynamicFilePath;
9 |
10 | public FileTraefikExporter(ILogger logger,string dynamicFilePath)
11 | {
12 | _dynamicFilePath = dynamicFilePath;
13 | logger.LogInformation("Exporting Traefik configuration to file: {file}", _dynamicFilePath);
14 | }
15 |
16 | public async Task ExportTraefikEntries(IDictionary oldEntries, IDictionary newEntries, CancellationToken cancellationToken)
17 | {
18 | if (oldEntries.SetEquals(newEntries))
19 | return;
20 |
21 | var root = new Node("traefik", [],[]);
22 |
23 | foreach (var (key, value) in newEntries)
24 | {
25 | var entryNode = Node.Create(key.Split("/"), value);
26 | root.Merge(entryNode);
27 | }
28 |
29 | var bld = new StringBuilder();
30 | GenerateFile(bld, root.Children);
31 | var content = bld.ToString();
32 |
33 | await using var file = File.Open(_dynamicFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
34 | await using var writer = new StreamWriter(file);
35 | await writer.WriteAsync(content);
36 | }
37 |
38 | private void GenerateFile(StringBuilder bld, IList nodes, int indentation = 0, int step = 4, char indentChar = ' ')
39 | {
40 | foreach (var node in nodes)
41 | {
42 | if (int.TryParse(node.Key, out _))
43 | {
44 | if (node.Children.Count > 0)
45 | {
46 | bld.Indent(indentation,indentChar)
47 | .Append("- ")
48 | .Append(node.Children[0].Key)
49 | .Append(": ")
50 | .Append(node.Children[0].Leaves[0].Value)
51 | .AppendLine();
52 |
53 | foreach (var child in node.Children.Skip(1))
54 | {
55 | bld.Indent(indentation,indentChar)
56 | .Append(" ")
57 | .Append(child.Key)
58 | .Append(": ")
59 | .Append(child.Leaves[0].Value)
60 | .AppendLine();
61 | }
62 | }
63 | else if (node.Leaves.Count > 0)
64 | {
65 | foreach (var leaf in node.Leaves)
66 | {
67 | bld.Indent(indentation,indentChar)
68 | .Append("- ")
69 | .Append(leaf.Value)
70 | .AppendLine();
71 | }
72 | }
73 |
74 | continue;
75 | }
76 |
77 |
78 | bld.Indent(indentation,indentChar)
79 | .Append(node.Key)
80 | .Append(':');
81 |
82 |
83 | if (node.Children.IsEmpty())
84 | {
85 | if (node.Leaves.Count == 1)
86 | bld.Append(' ')
87 | .Append(node.Leaves[0].Value)
88 | .AppendLine();
89 | }
90 | else
91 | {
92 | GenerateFile(bld.AppendLine(), node.Children, indentation + step);
93 | }
94 | }
95 | }
96 |
97 | private record Node(string Key, List Children, List Leaves)
98 | {
99 | internal static Node Create(Span path, string value)
100 | {
101 | return path switch
102 | {
103 | [var last] => new Node(last, [], [new Leaf(value)]),
104 | [var first, .. var other]=> new Node(first, [Create(other,value)],[]),
105 | _ => throw new ArgumentOutOfRangeException(nameof(path))
106 | };
107 | }
108 |
109 | internal void Merge(Node other)
110 | {
111 | if (Key != other.Key)
112 | throw new InvalidOperationException("Node key mismatch");
113 |
114 | foreach (var otherChild in other.Children)
115 | {
116 | var repeatedChildren = Children.FirstOrDefault(t => t.Key == otherChild.Key);
117 | if (repeatedChildren is not null)
118 | {
119 | repeatedChildren.Merge(otherChild);
120 | continue;
121 | }
122 | Children.Add(otherChild);
123 | }
124 |
125 | Leaves.AddRange(other.Leaves);
126 |
127 | }
128 | }
129 | private record Leaf(string Value);
130 | }
--------------------------------------------------------------------------------
/src/TraefikKobling.Worker/Worker.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Http.Json;
2 | using TraefikKobling.Worker.Configuration;
3 | using TraefikKobling.Worker.Exporters;
4 | using TraefikKobling.Worker.Extensions;
5 | using TraefikKobling.Worker.Traefik;
6 | using Server = TraefikKobling.Worker.Configuration.Server;
7 |
8 | namespace TraefikKobling.Worker;
9 |
10 | public class Worker(
11 | ILogger logger,
12 | IHttpClientFactory httpClientFactory,
13 | ITraefikExporter exporter,
14 | KoblingOptions options)
15 | : BackgroundService
16 | {
17 | private readonly Server[] _servers = options.Servers;
18 | private readonly int _runEvery = options.RunEvery;
19 |
20 | private readonly Dictionary _oldEntries = new();
21 |
22 | protected override async Task ExecuteAsync(CancellationToken stoppingToken)
23 | {
24 | while (!stoppingToken.IsCancellationRequested)
25 | {
26 | logger.LogInformation("Worker running at: {Time}", DateTimeOffset.Now);
27 |
28 | var entries = new Dictionary();
29 | foreach (var server in _servers)
30 | {
31 | try
32 | {
33 | entries.Merge(await GetHttpEntries(server, stoppingToken));
34 | entries.Merge(await GetTcpEntries(server, stoppingToken));
35 | }
36 | catch (Exception e)
37 | {
38 | logger.LogError(e, "Could not generate redis entries for {Server}", server.Name);
39 | }
40 | }
41 |
42 | await exporter.ExportTraefikEntries(_oldEntries, entries, stoppingToken);
43 |
44 | _oldEntries.Clear();
45 | _oldEntries.Merge(entries);
46 |
47 | await Task.Delay(_runEvery * 1000, stoppingToken);
48 | }
49 | }
50 |
51 | private async Task> GetTcpEntries(Server server, CancellationToken token)
52 | {
53 | var entries = new Dictionary();
54 |
55 | logger.LogInformation("Attempting to retrieve tcp routers from {Server}", server.Name);
56 | using var client = httpClientFactory.CreateClient(server.Name);
57 | using var response = await client.GetAsync("api/tcp/routers", token);
58 | if (!response.IsSuccessStatusCode)
59 | {
60 | logger.LogError("Could not connect to {Server}", server.Name);
61 | return entries;
62 | }
63 |
64 | logger.LogInformation("Successfully connected to {Server}", server.Name);
65 | logger.LogInformation("Retrieving tcp routers from {Server}", server.Name);
66 | var routers = await response.Content.ReadFromJsonAsync(GeneratedJsonInfo.Default.TcpRouterArray, token);
67 |
68 | if (routers is null)
69 | {
70 | logger.LogError("Could not get tcp routers from {Server}", server.Name);
71 | return entries;
72 | }
73 |
74 | if (routers.IsEmpty())
75 | {
76 | logger.LogInformation("No tcp routers found on {Server}", server.Name);
77 | return entries;
78 | }
79 |
80 | logger.LogInformation("Successfully retrieved {Number} tcp routers from {Server}",routers.Length, server.Name);
81 |
82 | entries[$"traefik/tcp/services/{server.Name}/loadbalancer/servers/0/url"] = server.DestinationAddress.ToString();
83 |
84 | foreach (var router in routers)
85 | {
86 | var name = router.Service;
87 |
88 | if (name.Contains('@'))
89 | name = $"{name.Split('@')[0]}_{server.Name}";
90 |
91 | var registeredEntryPoints = 0;
92 | foreach (var (global,local) in server.EntryPoints)
93 | {
94 | if (router.EntryPoints.Any(t=> t == local))
95 | entries[$"traefik/tcp/routers/{name}/entrypoints/{registeredEntryPoints++}"] = global;
96 | }
97 |
98 | if (registeredEntryPoints == 0) continue;
99 |
100 | entries[$"traefik/tcp/routers/{name}/rule"] = router.Rule;
101 | entries[$"traefik/tcp/routers/{name}/service"] = server.Name;
102 |
103 | }
104 |
105 | return entries;
106 | }
107 | private async Task> GetHttpEntries(Server server, CancellationToken token)
108 | {
109 | var entries = new Dictionary();
110 |
111 | logger.LogInformation("Attempting to retrieve http routers from {Server}", server.Name);
112 | using var client = httpClientFactory.CreateClient(server.Name);
113 | using var response = await client.GetAsync("api/http/routers", token);
114 |
115 | if (!response.IsSuccessStatusCode)
116 | {
117 | logger.LogError("Could not connect to {Server}", server.Name);
118 | return entries;
119 | }
120 |
121 | logger.LogInformation("Successfully connected to {Server}", server.Name);
122 | logger.LogInformation("Retrieving http routers from {Server}", server.Name);
123 | var routers = await response.Content.ReadFromJsonAsync(GeneratedJsonInfo.Default.HttpRouterArray,token);
124 |
125 | if (routers is null)
126 | {
127 | logger.LogError("Could not get http routers from {Server}", server.Name);
128 | return entries;
129 | }
130 |
131 | if (routers.IsEmpty())
132 | {
133 | logger.LogInformation("No http routers found on {Server}", server.Name);
134 | return entries;
135 | }
136 |
137 | logger.LogInformation("Successfully retrieved {Number} http routers from {Server}",routers.Length, server.Name);
138 | entries[$"traefik/http/services/{server.Name}/loadbalancer/servers/0/url"] = server.DestinationAddress.ToString();
139 |
140 | var middlewareNames = await GetMiddlewares(server, token);
141 | var serviceNames = await GetServices(server, token);
142 | foreach (var router in routers)
143 | {
144 | var name = router.Name;
145 |
146 | if (name.Contains('@'))
147 | name = $"{name.Split('@')[0]}_{server.Name}";
148 |
149 | var registeredEntryPoints = 0;
150 | foreach (var (global,local) in server.EntryPoints)
151 | {
152 | if (router.EntryPoints.Any(t=> t == local))
153 | entries[$"traefik/http/routers/{name}/entrypoints/{registeredEntryPoints++}"] = global;
154 | }
155 |
156 | if (registeredEntryPoints == 0) continue;
157 |
158 | entries[$"traefik/http/routers/{name}/rule"] = router.Rule;
159 | entries[$"traefik/http/routers/{name}/priority"] = router.Priority.ToString();
160 | entries[$"traefik/http/routers/{name}/service"] = server.Name;
161 |
162 | if (server.ForwardServices ?? options.ForwardServices ?? false)
163 | {
164 | if (!serviceNames.Contains(router.Name, StringComparer.OrdinalIgnoreCase) &&
165 | !serviceNames.Contains(router.Service, StringComparer.OrdinalIgnoreCase))
166 | {
167 | entries[$"traefik/http/routers/{name}/service"] = router.Service;
168 | }
169 | }
170 |
171 | if (server.ForwardMiddlewares ?? options.ForwardMiddlewares ?? false)
172 | {
173 | var registeredMiddlewares = 0;
174 | foreach (var middleware in router.Middlewares)
175 | {
176 | if (middlewareNames.Any(t=> t == middleware)) {
177 | entries[$"traefik/http/routers/{name}/middlewares/{registeredMiddlewares}"] = middleware;
178 | registeredMiddlewares++;
179 | }
180 | }
181 | }
182 | }
183 |
184 | return entries;
185 | }
186 |
187 |
188 | private async Task GetServices(Server server, CancellationToken token)
189 | {
190 | using var client = httpClientFactory.CreateClient(server.Name);
191 | using var response = await client.GetAsync("/api/http/services", token);
192 | if (!response.IsSuccessStatusCode)
193 | {
194 | logger.LogError("Could not connect to {Server}. Error: {StatusCode}", server.Name, response.StatusCode);
195 | return [];
196 | }
197 |
198 | var services = await response.Content.ReadFromJsonAsync(GeneratedJsonInfo.Default.ServiceArray,token);
199 | return services?.Select(t=> t.Name).ToArray() ?? [];
200 | }
201 |
202 | private async Task GetMiddlewares(Server server, CancellationToken token)
203 | {
204 | using var client = httpClientFactory.CreateClient(server.Name);
205 | using var response = await client.GetAsync("api/http/middlewares", token);
206 | if (!response.IsSuccessStatusCode)
207 | {
208 | logger.LogError("Could not connect to {Server}", server.Name);
209 | return [];
210 | }
211 |
212 | var middlewares = await response.Content.ReadFromJsonAsync(GeneratedJsonInfo.Default.MiddlewareArray,token);
213 | return middlewares?.Select(t=> t.Name).ToArray() ?? [];
214 | }
215 | }
216 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.rsuser
8 | *.suo
9 | *.user
10 | *.userosscache
11 | *.sln.docstates
12 |
13 | # User-specific files (MonoDevelop/Xamarin Studio)
14 | *.userprefs
15 |
16 | # Mono auto generated files
17 | mono_crash.*
18 |
19 | # Build results
20 | [Dd]ebug/
21 | [Dd]ebugPublic/
22 | [Rr]elease/
23 | [Rr]eleases/
24 | x64/
25 | x86/
26 | [Ww][Ii][Nn]32/
27 | [Aa][Rr][Mm]/
28 | [Aa][Rr][Mm]64/
29 | bld/
30 | [Bb]in/
31 | [Oo]bj/
32 | [Ll]og/
33 | [Ll]ogs/
34 |
35 | # Visual Studio 2015/2017 cache/options directory
36 | .vs/
37 | # Uncomment if you have tasks that create the project's static files in wwwroot
38 | #wwwroot/
39 |
40 | # Visual Studio 2017 auto generated files
41 | Generated\ Files/
42 |
43 | # MSTest test Results
44 | [Tt]est[Rr]esult*/
45 | [Bb]uild[Ll]og.*
46 |
47 | # NUnit
48 | *.VisualState.xml
49 | TestResult.xml
50 | nunit-*.xml
51 |
52 | # Build Results of an ATL Project
53 | [Dd]ebugPS/
54 | [Rr]eleasePS/
55 | dlldata.c
56 |
57 | # Benchmark Results
58 | BenchmarkDotNet.Artifacts/
59 |
60 | # .NET
61 | project.lock.json
62 | project.fragment.lock.json
63 | artifacts/
64 |
65 | # Tye
66 | .tye/
67 |
68 | # ASP.NET Scaffolding
69 | ScaffoldingReadMe.txt
70 |
71 | # StyleCop
72 | StyleCopReport.xml
73 |
74 | # Files built by Visual Studio
75 | *_i.c
76 | *_p.c
77 | *_h.h
78 | *.ilk
79 | *.meta
80 | *.obj
81 | *.iobj
82 | *.pch
83 | *.pdb
84 | *.ipdb
85 | *.pgc
86 | *.pgd
87 | *.rsp
88 | *.sbr
89 | *.tlb
90 | *.tli
91 | *.tlh
92 | *.tmp
93 | *.tmp_proj
94 | *_wpftmp.csproj
95 | *.log
96 | *.tlog
97 | *.vspscc
98 | *.vssscc
99 | .builds
100 | *.pidb
101 | *.svclog
102 | *.scc
103 |
104 | # Chutzpah Test files
105 | _Chutzpah*
106 |
107 | # Visual C++ cache files
108 | ipch/
109 | *.aps
110 | *.ncb
111 | *.opendb
112 | *.opensdf
113 | *.sdf
114 | *.cachefile
115 | *.VC.db
116 | *.VC.VC.opendb
117 |
118 | # Visual Studio profiler
119 | *.psess
120 | *.vsp
121 | *.vspx
122 | *.sap
123 |
124 | # Visual Studio Trace Files
125 | *.e2e
126 |
127 | # TFS 2012 Local Workspace
128 | $tf/
129 |
130 | # Guidance Automation Toolkit
131 | *.gpState
132 |
133 | # ReSharper is a .NET coding add-in
134 | _ReSharper*/
135 | *.[Rr]e[Ss]harper
136 | *.DotSettings.user
137 |
138 | # TeamCity is a build add-in
139 | _TeamCity*
140 |
141 | # DotCover is a Code Coverage Tool
142 | *.dotCover
143 |
144 | # AxoCover is a Code Coverage Tool
145 | .axoCover/*
146 | !.axoCover/settings.json
147 |
148 | # Coverlet is a free, cross platform Code Coverage Tool
149 | coverage*.json
150 | coverage*.xml
151 | coverage*.info
152 |
153 | # Visual Studio code coverage results
154 | *.coverage
155 | *.coveragexml
156 |
157 | # NCrunch
158 | _NCrunch_*
159 | .*crunch*.local.xml
160 | nCrunchTemp_*
161 |
162 | # MightyMoose
163 | *.mm.*
164 | AutoTest.Net/
165 |
166 | # Web workbench (sass)
167 | .sass-cache/
168 |
169 | # Installshield output folder
170 | [Ee]xpress/
171 |
172 | # DocProject is a documentation generator add-in
173 | DocProject/buildhelp/
174 | DocProject/Help/*.HxT
175 | DocProject/Help/*.HxC
176 | DocProject/Help/*.hhc
177 | DocProject/Help/*.hhk
178 | DocProject/Help/*.hhp
179 | DocProject/Help/Html2
180 | DocProject/Help/html
181 |
182 | # Click-Once directory
183 | publish/
184 |
185 | # Publish Web Output
186 | *.[Pp]ublish.xml
187 | *.azurePubxml
188 | # Note: Comment the next line if you want to checkin your web deploy settings,
189 | # but database connection strings (with potential passwords) will be unencrypted
190 | *.pubxml
191 | *.publishproj
192 |
193 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
194 | # checkin your Azure Web App publish settings, but sensitive information contained
195 | # in these scripts will be unencrypted
196 | PublishScripts/
197 |
198 | # NuGet Packages
199 | *.nupkg
200 | # NuGet Symbol Packages
201 | *.snupkg
202 | # The packages folder can be ignored because of Package Restore
203 | **/[Pp]ackages/*
204 | # except build/, which is used as an MSBuild target.
205 | !**/[Pp]ackages/build/
206 | # Uncomment if necessary however generally it will be regenerated when needed
207 | #!**/[Pp]ackages/repositories.config
208 | # NuGet v3's project.json files produces more ignorable files
209 | *.nuget.props
210 | *.nuget.targets
211 |
212 | # Microsoft Azure Build Output
213 | csx/
214 | *.build.csdef
215 |
216 | # Microsoft Azure Emulator
217 | ecf/
218 | rcf/
219 |
220 | # Windows Store app package directories and files
221 | AppPackages/
222 | BundleArtifacts/
223 | Package.StoreAssociation.xml
224 | _pkginfo.txt
225 | *.appx
226 | *.appxbundle
227 | *.appxupload
228 |
229 | # Visual Studio cache files
230 | # files ending in .cache can be ignored
231 | *.[Cc]ache
232 | # but keep track of directories ending in .cache
233 | !?*.[Cc]ache/
234 |
235 | # Others
236 | ClientBin/
237 | ~$*
238 | *~
239 | *.dbmdl
240 | *.dbproj.schemaview
241 | *.jfm
242 | *.pfx
243 | *.publishsettings
244 | orleans.codegen.cs
245 |
246 | # Including strong name files can present a security risk
247 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
248 | #*.snk
249 |
250 | # Since there are multiple workflows, uncomment next line to ignore bower_components
251 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
252 | #bower_components/
253 |
254 | # RIA/Silverlight projects
255 | Generated_Code/
256 |
257 | # Backup & report files from converting an old project file
258 | # to a newer Visual Studio version. Backup files are not needed,
259 | # because we have git ;-)
260 | _UpgradeReport_Files/
261 | Backup*/
262 | UpgradeLog*.XML
263 | UpgradeLog*.htm
264 | ServiceFabricBackup/
265 | *.rptproj.bak
266 |
267 | # SQL Server files
268 | *.mdf
269 | *.ldf
270 | *.ndf
271 |
272 | # Business Intelligence projects
273 | *.rdl.data
274 | *.bim.layout
275 | *.bim_*.settings
276 | *.rptproj.rsuser
277 | *- [Bb]ackup.rdl
278 | *- [Bb]ackup ([0-9]).rdl
279 | *- [Bb]ackup ([0-9][0-9]).rdl
280 |
281 | # Microsoft Fakes
282 | FakesAssemblies/
283 |
284 | # GhostDoc plugin setting file
285 | *.GhostDoc.xml
286 |
287 | # Node.js Tools for Visual Studio
288 | .ntvs_analysis.dat
289 | node_modules/
290 |
291 | # Visual Studio 6 build log
292 | *.plg
293 |
294 | # Visual Studio 6 workspace options file
295 | *.opt
296 |
297 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
298 | *.vbw
299 |
300 | # Visual Studio 6 auto-generated project file (contains which files were open etc.)
301 | *.vbp
302 |
303 | # Visual Studio 6 workspace and project file (working project files containing files to include in project)
304 | *.dsw
305 | *.dsp
306 |
307 | # Visual Studio 6 technical files
308 | *.ncb
309 | *.aps
310 |
311 | # Visual Studio LightSwitch build output
312 | **/*.HTMLClient/GeneratedArtifacts
313 | **/*.DesktopClient/GeneratedArtifacts
314 | **/*.DesktopClient/ModelManifest.xml
315 | **/*.Server/GeneratedArtifacts
316 | **/*.Server/ModelManifest.xml
317 | _Pvt_Extensions
318 |
319 | # Paket dependency manager
320 | .paket/paket.exe
321 | paket-files/
322 |
323 | # FAKE - F# Make
324 | .fake/
325 |
326 | # CodeRush personal settings
327 | .cr/personal
328 |
329 | # Python Tools for Visual Studio (PTVS)
330 | __pycache__/
331 | *.pyc
332 |
333 | # Cake - Uncomment if you are using it
334 | # tools/**
335 | # !tools/packages.config
336 |
337 | # Tabs Studio
338 | *.tss
339 |
340 | # Telerik's JustMock configuration file
341 | *.jmconfig
342 |
343 | # BizTalk build output
344 | *.btp.cs
345 | *.btm.cs
346 | *.odx.cs
347 | *.xsd.cs
348 |
349 | # OpenCover UI analysis results
350 | OpenCover/
351 |
352 | # Azure Stream Analytics local run output
353 | ASALocalRun/
354 |
355 | # MSBuild Binary and Structured Log
356 | *.binlog
357 |
358 | # NVidia Nsight GPU debugger configuration file
359 | *.nvuser
360 |
361 | # MFractors (Xamarin productivity tool) working folder
362 | .mfractor/
363 |
364 | # Local History for Visual Studio
365 | .localhistory/
366 |
367 | # Visual Studio History (VSHistory) files
368 | .vshistory/
369 |
370 | # BeatPulse healthcheck temp database
371 | healthchecksdb
372 |
373 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
374 | MigrationBackup/
375 |
376 | # Ionide (cross platform F# VS Code tools) working folder
377 | .ionide/
378 |
379 | # Fody - auto-generated XML schema
380 | FodyWeavers.xsd
381 |
382 | # VS Code files for those working on multiple tools
383 | .vscode/*
384 | !.vscode/settings.json
385 | !.vscode/tasks.json
386 | !.vscode/launch.json
387 | !.vscode/extensions.json
388 | *.code-workspace
389 |
390 | # Local History for Visual Studio Code
391 | .history/
392 |
393 | # Windows Installer files from build outputs
394 | *.cab
395 | *.msi
396 | *.msix
397 | *.msm
398 | *.msp
399 |
400 | # JetBrains Rider
401 | *.sln.iml
402 |
403 | ##
404 | ## Visual studio for Mac
405 | ##
406 |
407 |
408 | # globs
409 | Makefile.in
410 | *.userprefs
411 | *.usertasks
412 | config.make
413 | config.status
414 | aclocal.m4
415 | install-sh
416 | autom4te.cache/
417 | *.tar.gz
418 | tarballs/
419 | test-results/
420 |
421 | # Mac bundle stuff
422 | *.dmg
423 | *.app
424 |
425 | # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
426 | # General
427 | .DS_Store
428 | .AppleDouble
429 | .LSOverride
430 |
431 | # Icon must end with two \r
432 | Icon
433 |
434 |
435 | # Thumbnails
436 | ._*
437 |
438 | # Files that might appear in the root of a volume
439 | .DocumentRevisions-V100
440 | .fseventsd
441 | .Spotlight-V100
442 | .TemporaryItems
443 | .Trashes
444 | .VolumeIcon.icns
445 | .com.apple.timemachine.donotpresent
446 |
447 | # Directories potentially created on remote AFP share
448 | .AppleDB
449 | .AppleDesktop
450 | Network Trash Folder
451 | Temporary Items
452 | .apdisk
453 |
454 | # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
455 | # Windows thumbnail cache files
456 | Thumbs.db
457 | ehthumbs.db
458 | ehthumbs_vista.db
459 |
460 | # Dump file
461 | *.stackdump
462 |
463 | # Folder config file
464 | [Dd]esktop.ini
465 |
466 | # Recycle Bin used on file shares
467 | $RECYCLE.BIN/
468 |
469 | # Windows Installer files
470 | *.cab
471 | *.msi
472 | *.msix
473 | *.msm
474 | *.msp
475 |
476 | # Windows shortcuts
477 | *.lnk
478 | .idea
479 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Traefik Kobling
2 |
3 | A dynamic traefik to traefik discovery agent.
4 |
5 | *"Kobling"* means *"coupling"* or *"linking"* in Norwegian. Traefik Kobling lets homelab
6 | users link Traefik instances on different hosts to a main, public-facing Traefik instance
7 | without using Docker Swarm or publishing ports.
8 |
9 | Going forward, we will refer to the main, public-facing Traefik instance as the main instance
10 | and the others will be called local instances.
11 |
12 | This is accomplished by using Traefik's API on the local instances to find out which rules
13 | are registered and then publish them to redis, so the main instance can read and use them.
14 |
15 | ## Usage
16 |
17 | For the main traefik instance, you have to configure it to use the redis provider in `traefik.yml`:
18 |
19 | ```yml
20 | providers:
21 | # if you are using the Redis exporter
22 | redis:
23 | endpoints:
24 | - "localhost:6379" # change the address if the redis instance is not available in local host
25 | # if you are using the file exporter
26 | file:
27 | directory: ./dynamic
28 | watch: true
29 | # other providers
30 | ```
31 |
32 | Also, if you want to use HTTPS, it should be done in this instance.
33 |
34 | Configure all your local traefik instances to have API access enabled in their `traefik.yml`:
35 | - If the local traefik instances are located within your own local network, then you can allow insecure access and connect to it over port 8080:
36 | ```yml
37 | api:
38 | insecure: true
39 | ```
40 |
41 | - if your local traefik instances can be accessed through the internet, then you should not be allowing insecure access and you should set up some sort
42 | of authentication. Traefik Kobling only supports Basic Auth and here's a [guide](https://doc.traefik.io/traefik/operations/api/) on how to set it up in your instance.
43 |
44 | These instances do not (and probably should not) have HTTPS enabled.
45 |
46 | We can write our config file `config.yml`:
47 |
48 | ```yml
49 | servers:
50 | - name: "other-host"
51 | apiAddress: http://192.168.0.10:8080
52 | destinationAddress: http://192.168.0.10
53 | entryPoints:
54 | web: web
55 | websecure: web
56 | ```
57 |
58 | And then finally, we can set up Traefik Kobling on your main instance:
59 |
60 | - For the redis exporter
61 | ```yml
62 | services:
63 | traefik-kobling:
64 | image: ghcr.io/ldellisola/traefik-kobling
65 | volumes:
66 | - ./config.yml:/config.yml
67 | environment:
68 | TRAEFIK_EXPORTER: "redis"
69 | REDIS_URL: "localhost:6379"
70 | ```
71 | - for the file exporter:
72 | ```yml
73 | services:
74 | traefik-kobling:
75 | image: ghcr.io/ldellisola/traefik-kobling
76 | volumes:
77 | - ./config.yml:/config.yml
78 | - ./dynamic/kobling-dynamic-conf.yml:/dynamic-kobling.yml
79 | environment:
80 | TRAEFIK_EXPORTER: "file"
81 | TRAEFIK_DYNAMIC_CONFIG_PATH: "/dynamic-kobling.yml"
82 | ```
83 |
84 | ## Configuration
85 |
86 | There are two places where you can configure this application. First there are some
87 | environment variables:
88 | - `TRAEFIK_EXPORTER`: `redis` or `file`. It defines whether kobling will export the configuration as a file or using redis. It defaults to `redis`.
89 | - `REDIS_URL`: If you want to export to Redis, you have to specify the URL of the redis database. The default is `redis:6379`.
90 | - `TRAEFIK_DYNAMIC_CONFIG_PATH`:If you want to export to a file, you can specify the path where the file will be created inside the container. It defaults to `/dynamic-kobling.yml`
91 | - `CONFIG_PATH`: It lets the user change the location of the `config.yml` file. The default
92 | location is `/config.yml`.
93 | - `RUN_EVERY`: It specifies how many seconds to wait before checking the local instances for
94 | changes. The default value is 60 seconds.
95 |
96 | You must also create a `config.yml`. This file contains a list of servers with information
97 | about what the address of the local traefik instances are and where traffic should
98 | be redirected.
99 |
100 | ```yml
101 | servers:
102 | - name: "host-1"
103 | apiAddress: http://192.168.0.10:8080
104 | destinationAddress: http://192.168.0.10
105 | entryPoints:
106 | web: web
107 | websecure: web
108 |
109 | - name: "host-2"
110 | apiAddress: http://192.168.0.11:8080
111 | destinationAddress: http://192.168.0.11
112 | entryPoints:
113 | web-tcp: local-tcp
114 |
115 | - name: "host-3"
116 | apiAddress: http://192.168.0.12:8080
117 | destinationAddress: http://192.168.0.12
118 | ```
119 | The `entryPoints` mapping works in the following way:
120 |
121 | The entrypoints of your main instance are on the left and the entrypoint of the local instance are on the right, where ultimately traffic will be forwarded.
122 |
123 | This approach means we do not have to register more routers than necessary and it helps keep our main dashboard clean.
124 |
125 | If no entrypoints are provided in the configuration, the default value `http` is used for both the main instance as well as local instances.
126 | ### Connecting to Traefik instances with Basic Auth
127 | If your local instance has basic auth enabled, then you have to specify it in the Kobling config:
128 | ```yml
129 | servers:
130 | - name: "host-1"
131 | apiAddress: http://username:password@192.168.0.10
132 | apiHost: traefik.domain.tld
133 | destinationAddress: http://192.168.0.10
134 | entryPoints:
135 | web: web
136 | websecure: web
137 | ```
138 | The address in `apiAddress` should include the username and password to access the api and `apiHost` should be the host name for that service.
139 |
140 | ### Forwarding Services
141 | Starting on version 0.2.0, Traefik Kobling can now identify if your internal router references a service that is not defined in the internal traefik instance.
142 |
143 | So if you have a router with a custom service, for example:
144 | ```yml
145 | services:
146 | whoami:
147 | # ...
148 | labels:
149 | # ...
150 | traefik.http.routers.utgifter-auth.service: "authentik@file"
151 | ```
152 | It will look in the main instance for the `authentik@file` service.
153 |
154 | ### Forwarding Middlewares
155 |
156 | Starting on version 0.2.0, Traefik Kobling can now forward middleware usage from internal instances to the main instance.
157 | This is enabled by the `forwardMiddlewares` property on the `config.yml` file.
158 | This option can be controlled globally:
159 | ```yml
160 | forwardMiddlewares: true
161 | servers:
162 | - name: "host-1"
163 | apiAddress: http://username:password@192.168.0.10
164 | apiHost: traefik.domain.tld
165 | destinationAddress: http://192.168.0.10
166 | entryPoints:
167 | web: web
168 | websecure: web
169 | ```
170 | Or in a per-server basis:
171 | ```yml
172 | servers:
173 | - name: "host-1"
174 | apiAddress: http://username:password@192.168.0.10
175 | apiHost: traefik.domain.tld
176 | destinationAddress: http://192.168.0.10
177 | forwardMiddlewares: true
178 | entryPoints:
179 | web: web
180 | websecure: web
181 | ```
182 | In practice, it means that whatever middleware you registered to a router:
183 | ```yml
184 | services:
185 | whoami:
186 | # ...
187 | labels:
188 | traefik.http.routers.whoami.middlewares: auth@file
189 | ```
190 | This dependency will be brought to the main instance, and this instance will be the one responsible for finding the `auth@file` middleware.
191 | This feature will not copy middleware definitions from internal instances into external ones.
192 |
193 | This approach has one problem:
194 | If your router in the internal traefik depends on a middlware that does not exists, the router will be skipped during request matching.
195 | There are 2 ways around this:
196 |
197 | 1. Create a second router that matches to the same container but does not have the middleware dependency. You need to make sure that this new router has less priority than the original
198 | ```yml
199 | services:
200 | whoami:
201 | # ...
202 | labels:
203 | traefik.http.routers.whoami.rule: "Host(`whoami.lud.ar`)"
204 | # longer rule means less priority
205 | traefik.http.routers.whoami-test.rule: "Host(`whoami.lud.ar`) && Host(`whoami.lud.ar`)"
206 | traefik.http.services.whoami.loadbalancer.server.port: "80"
207 | traefik.http.routers.whoami.middlewares: auth@file
208 | ```
209 | In this case, we are defining 2 routers for the `whoami` service. The main traefik instance will have both routers: `whoami` and `whoami-test` but will always match the first one because of the priority. The internal instance will have 2 routers, but `whoami` will not be valid because it is missing the middleware implementation, so all requests will match to `whoami-test` and the public instance will handle the middleware.
210 |
211 | 2. Create a mock middleware with the same name on your internal traefik instance.
212 | ```yml
213 | http:
214 | middlewares:
215 | auth:
216 | redirectRegex: # a dummy, do-nothing redirect
217 | regex: "^/$"
218 | replacement: "/"
219 | ```
220 | This way the entry on both instances will be valid, but only the one on the public instance will run the actual middleware.
221 |
222 | ## Example
223 |
224 | So what does this mean?
225 |
226 | Let's say we have 2 machines in our home network:
227 | - Machine A is exposing port 80 and 443 to the internet and it is running the following containers:
228 | - `traefik`: this is our main instance and has the dashboard exposed in the FQDN `main.domain.tld`
229 | - `redis`: it's the storage for the main instance to use as a provider.
230 | - `traefik koblink`: it will read data from the traefik instance in machine B and provide redirect data
231 | for the main traefik instance.
232 |
233 | For this machine we will deploy the following `docker-compose.yml` file:
234 |
235 | ```yml
236 | networks:
237 | default:
238 | name: "web"
239 |
240 | services:
241 | traefik:
242 | image: traefik:latest
243 | ports:
244 | - 80:80
245 | - 443:443
246 | environment:
247 | CF_API_EMAIL: ${CF_API_EMAIL}
248 | CF_API_KEY: ${CF_API_KEY}
249 | volumes:
250 | - /var/run/docker.sock:/var/run/docker.sock
251 | - ./traefik.yml:/traefik.yml
252 | - ./acme.json:/acme.json
253 |
254 | redis:
255 | image: redis:alpine
256 |
257 | traefik-kobling:
258 | image: ghcr.io/ldellisola/traefik-kobling
259 | volumes:
260 | - ./config.yml:/config.yml
261 | environment:
262 | REDIS_URL: "redis:6379"
263 | ```
264 |
265 | The `traefik.yml` looks like this:
266 |
267 | ```yml
268 | api:
269 | dashboard: true
270 |
271 | entryPoints:
272 | web:
273 | address: ":80"
274 | http:
275 | redirections:
276 | entrypoint:
277 | to: web-secure
278 | scheme: https
279 |
280 | web-secure:
281 | address: ":433"
282 | http:
283 | tls:
284 | certResolver: "cloudflare"
285 | domains:
286 | - main: "domain.tld"
287 | sans:
288 | - "*.domain.tld"
289 |
290 | serversTransport:
291 | insecureSkipVerify: true
292 |
293 | providers:
294 | redis:
295 | endpoints:
296 | - "redis:6379"
297 |
298 | certificatesResolvers:
299 | cloudflare:
300 | acme:
301 | email: me@domain.tld
302 | storage: /acme.json
303 | dnsChallenge:
304 | provider: cloudflare
305 | resolvers:
306 | - "1.1.1.1:53"
307 | - "1.0.0.1:53"
308 | ```
309 |
310 | The `config.yml` looks like this:
311 | ```yml
312 | servers:
313 | - name: "machine-b"
314 | apiAddress: http://192.168.0.10:8080
315 | destinationAddress: http://192.168.0.10
316 | entryPoints:
317 | web: local
318 | web-secure: local
319 | ```
320 |
321 | - Machine B is on IP 192.168.0.10 and it runs:
322 | - `traefik`: this is a local instance and has the FQDN `local.domain.tld`
323 | - `Service B`: another random service hosted in this server, with the FQDN `serviceB.domain.tld`
324 |
325 | It is deployed with the following `docker-compose.yml`:
326 |
327 | ```yml
328 | networks:
329 | web:
330 | name: "web"
331 |
332 | services:
333 | traefik:
334 | image: traefik:latest
335 | ports:
336 | - 80:80
337 | - 8080:8080
338 | networks:
339 | - web
340 | volumes:
341 | - /var/run/docker.sock:/var/run/docker.sock
342 | - ./traefik.yml:/traefik.yml
343 | labels:
344 | traefik.enable: "true"
345 | traefik.http.routers.traefik.rule: "Host(`local.domain.tld`)
346 | traefik.http.routers.traefik.service: "api@internal"
347 | traefik.http.services.traefik.loadbalancer.server.port: "8080"
348 |
349 | service-b:
350 | image: service-b:latest
351 | networks:
352 | - web
353 | labels:
354 | traefik.enable: "true"
355 | traefik.http.routers.service-b.rule: "Host(`serviceB.domain.tld`)
356 | traefik.http.services.service-b.loadbalancer.server.port: "8080"
357 | ```
358 |
359 | And the `traefik.yml` is:
360 |
361 | ```yml
362 | api:
363 | insecure: true
364 |
365 | entryPoints:
366 | local:
367 | address: ":80"
368 |
369 | providers:
370 | docker:
371 | endpoint: "unix:///var/run/docker.sock"
372 | exposedByDefault: false
373 | network: web
374 | ```
375 |
376 | The main traefik instance is set up with https but the local ones do not have to, and both
377 | instances are set up to redirect trafic to the services within the machine according to their
378 | domain name.
379 |
380 | So, if we want to access `serviceB.domain.tld`, the request should be redirected like:
381 |
382 | ```text
383 | [internet] -- serviceB.domain.tld --> [main traefik]
384 | [main traefik] -- serviceB.domain.tld --> [local traefik] on 192.168.0.10
385 | [local traefik] --> [Service B]
386 | ```
387 |
388 | ### Integration with Authentik
389 | Both additions on version 0.2.0 were made to support authentik and other domain level proxy authentication providers.
390 | If you define the following middleware on your main traefik instance:
391 | ```yml
392 | http:
393 | middlewares:
394 | auth:
395 | forwardAuth:
396 | address: http://authentik.domain.tld:9000/outpost.goauthentik.io/auth/traefik
397 | trustForwardHeader: true
398 | authResponseHeaders:
399 | - X-authentik-username
400 | - X-authentik-groups
401 | - X-authentik-entitlements
402 | - X-authentik-email
403 | - X-authentik-name
404 | - X-authentik-uid
405 | - X-authentik-jwt
406 | - X-authentik-meta-jwks
407 | - X-authentik-meta-outpost
408 | - X-authentik-meta-provider
409 | - X-authentik-meta-app
410 | - X-authentik-meta-version
411 |
412 | routers:
413 | authentik:
414 | entryPoints:
415 | - web
416 | - web-secure
417 | service: authentik
418 | rule: Host(`authentik.domain.tld`)
419 | services:
420 | authentik:
421 | loadBalancer:
422 | servers:
423 | - url: http://authentik.domain.tld:9000
424 | ```
425 | On your internal instance you define the following middleware:
426 | ```yml
427 | http:
428 | middlewares:
429 | auth:
430 | redirectRegex: # a dummy, do-nothing redirect
431 | regex: "^/$"
432 | replacement: "/"
433 | ```
434 | Then, you can define your services like:
435 | ```yml
436 | services:
437 | whoami:
438 | # ...
439 | labels:
440 | traefik.enable: "true"
441 | traefik.http.routers.whoami.rule: "Host(`whoami.domain.tld`)"
442 | traefik.http.services.whoami.loadbalancer.server.port: "80"
443 | traefik.http.routers.whoami.middlewares: auth@file
444 | traefik.http.routers.whoami-auth.rule: "Host(`whoami.domain.tld`) && PathPrefix(`/outpost.goauthentik.io/`)"
445 | traefik.http.routers.whoami-auth.service: "authentik@file"
446 | ```
447 | And remember to enable the `forwardMiddlewares` feature on that server or globally.
448 |
449 | ## License
450 | - Traefik Kobling: MIT, (c) 2023 Lucas Dell'Isola.
451 | - traefik: MIT, Copyright (c) 2016-2020 Containous SAS; 2020-2023 Traefik Labs
452 |
--------------------------------------------------------------------------------