Skip to content

Repository files navigation

Azure SQL Firewall Sync

Agente .NET 10 (Worker Service) que roda continuamente em Linux/Docker e mantém uma regra de firewall de um Azure SQL Server sincronizada com o IP público atual da máquina onde ele executa. Pensado para ambientes com IP dinâmico (home office, laboratórios, VMs on-premises sem IP fixo) que precisam de acesso confiável a um Azure SQL Server sem abrir o firewall para 0.0.0.0/0.

Toda comunicação com a Azure usa o SDK oficial (Azure.Identity + Azure.ResourceManager.Sql). Não há dependência da Azure CLI nem execução de processos externos — o binário é autossuficiente.


Sumário


Objetivo

Manter uma única regra de firewall do Azure SQL Server sempre apontando para o IP público atual da máquina, sem intervenção manual e sem depender de IP fixo/VPN. O agente:

  1. Descobre o IP público atual da máquina.
  2. Compara com o último IP conhecido (persistido localmente).
  3. Se não mudou, apenas loga e aguarda o próximo ciclo — nenhuma chamada à Azure é feita.
  4. Se mudou, autentica na Azure via Service Principal e cria/atualiza a regra de firewall configurada.

O processo roda para sempre; falhas temporárias (rede instável, Azure fora do ar, credenciais momentaneamente inválidas) nunca derrubam o worker — elas são logadas e uma nova tentativa ocorre no próximo ciclo.

Arquitetura

O projeto segue Clean Architecture, com a regra de negócio (Application) isolada de qualquer detalhe de infraestrutura (Infrastructure). Todas as dependências são injetadas via interfaces.

src/AzureSqlFirewallSync/
├── Application/
│   ├── Interfaces/        # Contratos: IPublicIpProvider, IIpAddressStore,
│   │                       IFirewallRuleSyncer, IFirewallSyncOrchestrator, ISyncHealthState
│   └── Services/          # FirewallSyncOrchestrator (regra de negócio), SyncHealthState
├── Domain/                 # PublicIpAddress, FirewallSyncResult, exceções de domínio
├── Infrastructure/
│   ├── Azure/              # ArmClient, ClientSecretCredential, SqlFirewallRuleSyncer,
│   │                        pipeline de retry (Polly)
│   ├── Networking/         # PublicIpProvider — descoberta de IP com fallback entre provedores
│   └── Persistence/        # FileIpAddressStore — estado local em JSON, escrita atômica
├── Worker/                 # FirewallSyncWorker (BackgroundService) e SyncHealthCheck
├── Configuration/          # Options Pattern (Worker, Networking, Azure, Persistence, Resilience)
└── Program.cs               # Composition root (DI, Serilog, health checks)

Nenhuma camada de Application/Domain referencia Azure.*, HttpClient ou o sistema de arquivos diretamente — tudo passa pelas interfaces em Application/Interfaces, o que torna trivial, por exemplo, trocar o provedor de IP público ou o backend de persistência sem tocar na regra de negócio.

Por que o projeto é um "Worker Service com endpoint HTTP"?

O template base é Microsoft.NET.Sdk.Worker (sem Kestrel), mas o projeto referencia Microsoft.AspNetCore.App (FrameworkReference) para expor um único endpoint HTTP, /health, consumido pelo HEALTHCHECK do Docker e por orquestradores (Kubernetes liveness/readiness probes, por exemplo). Essa é a única razão para o ASP.NET Core estar presente — não há controllers, views ou APIs de negócio. Veja Decisões arquiteturais para mais detalhes.

Fluxo de funcionamento

sequenceDiagram
    participant W as FirewallSyncWorker
    participant O as FirewallSyncOrchestrator
    participant IP as IPublicIpProvider
    participant S as IIpAddressStore
    participant AZ as IFirewallRuleSyncer (Azure)

    loop A cada Worker:IntervalSeconds
        W->>O: RunCycleAsync()
        O->>IP: GetCurrentIpAsync()
        IP-->>O: IP atual (ou exceção após esgotar provedores)
        O->>S: ReadLastKnownIpAsync()
        alt IP não mudou
            O-->>W: Unchanged
        else IP mudou
            O->>AZ: ApplyAsync(novoIp)
            AZ-->>O: sucesso ou FirewallSyncException
            alt sucesso
                O->>S: SaveAsync(novoIp)
                O-->>W: Updated
            else falha
                O-->>W: Failed (IP local NÃO é persistido)
            end
        end
        W->>W: aguarda IntervalSeconds
    end
Loading

Pontos importantes do fluxo:

  • Descoberta de IP com fallback: Networking:Providers é uma lista ordenada; o primeiro provedor que responder com um IP válido "vence". Se um provedor falhar ou devolver lixo, o próximo da lista é tentado automaticamente.
  • IP local só é persistido após sucesso na Azure. Se a atualização do firewall falhar, o arquivo de estado local não é atualizado — assim, o próximo ciclo tenta novamente em vez de achar (erroneamente) que já está sincronizado.
  • Nenhuma exceção derruba o worker: tanto a descoberta de IP quanto a sincronização com a Azure são protegidas por try/catch em FirewallSyncOrchestrator, e o FirewallSyncWorker tem uma segunda rede de segurança para qualquer erro inesperado.

Configuração

Toda configuração é feita via appsettings.json (com suporte a appsettings.{Environment}.json) e pode ser sobrescrita por variáveis de ambiente, seguindo a convenção padrão do .NET (Seção__Chave).

{
  "Worker": {
    "IntervalSeconds": 300
  },
  "Networking": {
    "Providers": [
      "https://api.ipify.org",
      "https://ifconfig.me/ip",
      "https://icanhazip.com"
    ],
    "RequestTimeoutSeconds": 10
  },
  "Persistence": {
    "StateFilePath": "data/last-known-ip.json"
  },
  "Resilience": {
    "MaxRetryAttempts": 3,
    "BaseDelaySeconds": 2
  },
  "Azure": {
    "TenantId": "",
    "ClientId": "",
    "ClientSecret": "",
    "SubscriptionId": "",
    "ResourceGroup": "",
    "SqlServerName": "",
    "FirewallRuleName": "AzureSqlFirewallSync-DynamicIp"
  }
}
Seção / Chave Descrição
Worker.IntervalSeconds Intervalo entre ciclos de verificação (mínimo 10s).
Networking.Providers Lista ordenada de serviços HTTP que retornam o IP público em texto puro.
Networking.RequestTimeoutSeconds Timeout por tentativa de provedor.
Persistence.StateFilePath Caminho do arquivo JSON com o último IP conhecido (relativo ao diretório de trabalho, ou absoluto).
Resilience.MaxRetryAttempts Tentativas de retry (backoff exponencial) para autenticação e atualização do firewall.
Resilience.BaseDelaySeconds Delay-base do backoff exponencial (2s, 4s, 8s, ... por tentativa).
Azure.TenantId ID do tenant do Microsoft Entra ID.
Azure.ClientId Application (client) ID do Service Principal.
Azure.ClientSecret Client secret do Service Principal. Nunca versione este valor.
Azure.SubscriptionId ID da assinatura onde o Azure SQL Server está.
Azure.ResourceGroup Resource Group do Azure SQL Server.
Azure.SqlServerName Nome do recurso "Azure SQL logical server" (não do banco de dados).
Azure.FirewallRuleName Nome da regra de firewall a criar/atualizar.

Sobrescrita por variáveis de ambiente

export Azure__TenantId="00000000-0000-0000-0000-000000000000"
export Azure__ClientId="11111111-1111-1111-1111-111111111111"
export Azure__ClientSecret="segredo-super-secreto"
export Azure__SubscriptionId="22222222-2222-2222-2222-222222222222"
export Azure__ResourceGroup="rg-meu-projeto"
export Azure__SqlServerName="meu-servidor-sql"
export Worker__IntervalSeconds="300"

Em desenvolvimento local, prefira dotnet user-secrets a colocar segredos em appsettings.Development.json:

cd src/AzureSqlFirewallSync
dotnet user-secrets set "Azure:ClientSecret" "segredo-super-secreto"

Execução local

Pré-requisitos: .NET 10 SDK.

cd src/AzureSqlFirewallSync
dotnet restore
dotnet run

O endpoint de health check fica disponível em http://localhost:8080/health (porta configurável em Kestrel:Endpoints:Health:Url no appsettings.json).

Para rodar os testes:

dotnet test

Execução via Docker

cp .env.example .env
# edite o .env com TenantId, ClientId, ClientSecret, SubscriptionId, ResourceGroup, SqlServerName

docker compose up -d --build
docker compose logs -f

A imagem final (mcr.microsoft.com/dotnet/aspnet:10.0):

  • roda como usuário não-root (app, UID/GID herdados de $APP_UID da imagem base);
  • monta o sistema de arquivos raiz como somente-leitura (read_only: true no docker-compose.yml), com /tmp e o $HOME do usuário em tmpfs;
  • persiste o último IP conhecido em um volume nomeado (firewall-sync-data:/app/data), sobrevivendo a docker compose down/up;
  • expõe HEALTHCHECK nativo do Docker consultando GET /health a cada 30s.
docker inspect --format='{{json .State.Health}}' azure-sql-firewall-sync

Criando um Service Principal

O agente autentica via Service Principal (Client Secret). Recomenda-se o Portal do Azure para não depender de nenhuma CLI, mas os comandos equivalentes via Azure CLI (para quem já tem o ambiente configurado) também são listados como atalho.

Via Portal do Azure

  1. Microsoft Entra IDRegistros de aplicativoNovo registro.
    • Nome: azure-sql-firewall-sync (ou outro de sua preferência).
    • Tipos de conta com suporte: Somente contas neste diretório organizacional.
  2. Após criado, anote em Visão geral:
    • Application (client) IDAzure:ClientId
    • Directory (tenant) IDAzure:TenantId
  3. Certificados e segredosNovo segredo do cliente.
    • Defina uma expiração (ex.: 12 ou 24 meses) e anote o Valor imediatamente — ele só é exibido uma vez → Azure:ClientSecret.
  4. Conceda a permissão no recurso (veja a próxima seção) em Azure SQL Server → Controle de acesso (IAM) → Adicionar atribuição de função, selecionando o App Registration criado.

Via Azure CLI (opcional, apenas para criar o Service Principal — o agente em si nunca usa a CLI)

az ad sp create-for-rbac \
  --name "azure-sql-firewall-sync" \
  --skip-assignment

# Some a role de acordo com a seção "Permissões mínimas necessárias" abaixo.

Permissões mínimas necessárias

Evite conceder Contributor na assinatura inteira. O Service Principal precisa apenas de permissão para ler/criar/atualizar regras de firewall no servidor SQL específico.

Opção recomendada: função customizada (least privilege)

Crie uma função customizada com escopo no recurso do Azure SQL Server:

{
  "Name": "SQL Firewall Rule Manager",
  "Description": "Permite ler e gerenciar apenas regras de firewall de um Azure SQL Server.",
  "Actions": [
    "Microsoft.Sql/servers/firewallRules/read",
    "Microsoft.Sql/servers/firewallRules/write"
  ],
  "NotActions": [],
  "AssignableScopes": [
    "/subscriptions/<SUBSCRIPTION_ID>/resourceGroups/<RESOURCE_GROUP>/providers/Microsoft.Sql/servers/<SQL_SERVER_NAME>"
  ]
}

Atribua essa função ao Service Principal com escopo no recurso do SQL Server (não no Resource Group nem na assinatura).

Alternativa mais simples

Atribuir a função interna SQL Server Contributor (Microsoft.Sql/servers/*) com escopo no Resource Group ou no próprio recurso do SQL Server. Mais permissiva que o necessário, mas evita criar uma função customizada.

Obtendo os IDs da Azure

Todos os valores abaixo podem ser obtidos pelo Portal do Azure, sem CLI:

  • Tenant ID: Microsoft Entra ID → Visão geral → Tenant ID.
  • Subscription ID: Assinaturas → selecione a assinatura → ID da assinatura.
  • Resource Group: nome exibido na página de qualquer recurso dentro dele, ou em Grupos de recursos.
  • SQL Server Name: no recurso do tipo Servidores SQL (não confundir com o nome do banco de dados — é o nome do servidor lógico, ex.: meu-servidor, sem o sufixo .database.windows.net).
  • Client ID / Client Secret: gerados na etapa Criando um Service Principal.

Troubleshooting

Sintoma Causa provável Solução
AADSTS900023: Specified tenant identifier ... is neither a valid... Azure:TenantId incorreto ou vazio Confirme o Tenant ID no Microsoft Entra ID.
AADSTS7000215: Invalid client secret provided Secret expirado, incorreto ou copiado com espaços extras Gere um novo Client Secret e atualize a configuração.
AuthorizationFailed / 403 ao atualizar o firewall Service Principal sem a role correta no recurso Revise Permissões mínimas necessárias.
Todos os N provedores de IP falharam Firewall de saída/proxy bloqueando os provedores configurados Adicione/troque provedores em Networking:Providers, ou libere saída HTTPS para eles.
Health check fica Unhealthy após alguns minutos 3+ ciclos consecutivos falharam (ver logs) Verifique o log do ciclo mais recente — geralmente é credencial ou conectividade.
Container reinicia mas volta a chamar a Azure mesmo sem IP ter mudado Volume firewall-sync-data não montado (estado local perdido a cada restart) Confirme o volume no docker-compose.yml; sem ele, cada restart perde o último IP conhecido, resultando em UMA chamada extra à Azure (não recorrente).
IOException/UnauthorizedAccessException ao persistir o IP Persistence:StateFilePath aponta para um diretório sem permissão de escrita No Docker, confirme que o caminho está dentro do volume montado (/app/data).

Logs estruturados (Serilog) podem ser emitidos em formato JSON definindo Logging:Json = true — útil para agregadores como ELK/Loki/Application Insights.

Decisões arquiteturais

  • Health check via ASP.NET Core mínimo: o projeto adiciona FrameworkReference para Microsoft.AspNetCore.App unicamente para expor /health via Microsoft.Extensions.Diagnostics.HealthChecks. Alternativas consideradas — heartbeat em arquivo lido por um script no HEALTHCHECK, ou nenhum health check — foram descartadas por serem menos observáveis e não se integrarem nativamente com docker inspect/Kubernetes probes.
  • Retry com Polly (v8) diretamente no SqlFirewallRuleSyncer, em vez de decorar o HttpClient do ARM SDK: o Azure.ResourceManager já expõe um HttpClient interno gerido pelo próprio SDK; a forma suportada de compor retries adicionais em cima dele é envolver a chamada com um ResiliencePipeline explícito, o que também nos dá controle sobre logging de cada tentativa.
  • Microsoft.Extensions.Http.Resilience para a descoberta de IP, em vez do pacote legado Microsoft.Extensions.Http.Polly/Polly.Extensions.Http: o pacote legado é baseado na API v7 do Polly (IAsyncPolicy), incompatível com o pacote unificado Polly v8 usado no restante do projeto. Microsoft.Extensions.Http.Resilience é a integração oficial e atual para IHttpClientBuilder.
  • Persistência local sem banco de dados: um único arquivo JSON, escrito atomicamente (grava em arquivo temporário e usa File.Move com overwrite) evita corromper o estado em caso de crash no meio da escrita — suficiente para armazenar um único valor (o último IP).
  • IP local só é persistido após sucesso da chamada à Azure (e não antes): garante que uma falha parcial nunca deixe o sistema "achando" que está sincronizado quando não está.

Roadmap

  • Suporte a autenticação via Managed Identity como alternativa ao Service Principal (útil quando o worker roda dentro do próprio Azure, ex.: Container Apps/AKS).
  • Suporte a múltiplas regras de firewall (ex.: múltiplos servidores SQL sincronizados a partir do mesmo agente).
  • Métricas Prometheus (/metrics) além do health check.
  • Publicação de imagem multiarquitetura (linux/amd64 + linux/arm64) via GitHub Actions.
  • Notificação opcional (webhook/Teams/Slack) quando o IP mudar ou quando o health check ficar Unhealthy.

About

Agente .NET 10 (Worker Service) que roda continuamente em Linux/Docker e mantém uma regra de firewall de um Azure SQL Server sincronizada com o IP público atual da máquina onde ele executa.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages