├── 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 | --------------------------------------------------------------------------------