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.
- Objetivo
- Arquitetura
- Fluxo de funcionamento
- Configuração
- Execução local
- Execução via Docker
- Criando um Service Principal
- Permissões mínimas necessárias
- Obtendo os IDs da Azure
- Troubleshooting
- Decisões arquiteturais
- Roadmap
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:
- Descobre o IP público atual da máquina.
- Compara com o último IP conhecido (persistido localmente).
- Se não mudou, apenas loga e aguarda o próximo ciclo — nenhuma chamada à Azure é feita.
- 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.
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.
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.
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
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/catchemFirewallSyncOrchestrator, e oFirewallSyncWorkertem uma segunda rede de segurança para qualquer erro inesperado.
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. |
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"Pré-requisitos: .NET 10 SDK.
cd src/AzureSqlFirewallSync
dotnet restore
dotnet runO 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 testcp .env.example .env
# edite o .env com TenantId, ClientId, ClientSecret, SubscriptionId, ResourceGroup, SqlServerName
docker compose up -d --build
docker compose logs -fA imagem final (mcr.microsoft.com/dotnet/aspnet:10.0):
- roda como usuário não-root (
app, UID/GID herdados de$APP_UIDda imagem base); - monta o sistema de arquivos raiz como somente-leitura (
read_only: truenodocker-compose.yml), com/tmpe o$HOMEdo usuário emtmpfs; - persiste o último IP conhecido em um volume nomeado (
firewall-sync-data:/app/data), sobrevivendo adocker compose down/up; - expõe
HEALTHCHECKnativo do Docker consultandoGET /healtha cada 30s.
docker inspect --format='{{json .State.Health}}' azure-sql-firewall-syncO 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.
- Microsoft Entra ID → Registros de aplicativo → Novo registro.
- Nome:
azure-sql-firewall-sync(ou outro de sua preferência). - Tipos de conta com suporte: Somente contas neste diretório organizacional.
- Nome:
- Após criado, anote em Visão geral:
- Application (client) ID →
Azure:ClientId - Directory (tenant) ID →
Azure:TenantId
- Application (client) ID →
- Certificados e segredos → Novo segredo do cliente.
- Defina uma expiração (ex.: 12 ou 24 meses) e anote o Valor imediatamente — ele só é
exibido uma vez →
Azure:ClientSecret.
- Defina uma expiração (ex.: 12 ou 24 meses) e anote o Valor imediatamente — ele só é
exibido uma vez →
- 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.
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.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.
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).
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.
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.
| 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.
- Health check via ASP.NET Core mínimo: o projeto adiciona
FrameworkReferenceparaMicrosoft.AspNetCore.Appunicamente para expor/healthviaMicrosoft.Extensions.Diagnostics.HealthChecks. Alternativas consideradas — heartbeat em arquivo lido por um script noHEALTHCHECK, ou nenhum health check — foram descartadas por serem menos observáveis e não se integrarem nativamente comdocker inspect/Kubernetes probes. - Retry com Polly (v8) diretamente no
SqlFirewallRuleSyncer, em vez de decorar oHttpClientdo ARM SDK: oAzure.ResourceManagerjá expõe umHttpClientinterno gerido pelo próprio SDK; a forma suportada de compor retries adicionais em cima dele é envolver a chamada com umResiliencePipelineexplícito, o que também nos dá controle sobre logging de cada tentativa. Microsoft.Extensions.Http.Resiliencepara a descoberta de IP, em vez do pacote legadoMicrosoft.Extensions.Http.Polly/Polly.Extensions.Http: o pacote legado é baseado na API v7 do Polly (IAsyncPolicy), incompatível com o pacote unificadoPollyv8 usado no restante do projeto.Microsoft.Extensions.Http.Resilienceé a integração oficial e atual paraIHttpClientBuilder.- Persistência local sem banco de dados: um único arquivo JSON, escrito atomicamente
(grava em arquivo temporário e usa
File.Movecom 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á.
- 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.