Programação Por Equipe DevBras

Capa do artigo: Instalador .NET com a connection string do seu PC

Instalador .NET com a connection string do seu PC

O MSI levou a connection string do seu PC para o cliente? Prepare o app .NET antes: configuração no primeiro uso no AppData e senha protegida com DPAPI.

"Não foi possível conectar ao servidor NOTE-DEV01." A mensagem aparece na tela do cliente no primeiro clique depois da instalação, e NOTE-DEV01 é o notebook de quem desenvolveu o sistema. O MSI fez exatamente o que devia: copiou a saída do build, e a saída do build tinha a connection string de desenvolvimento dentro do appsettings.json. O problema não está no instalador, e sim no aplicativo, que ainda não sabe viver fora da máquina onde nasceu.

Este post mostra o que mudar no código de um programa de desktop .NET antes de gerar o MSI: configuração criada no primeiro uso, senha protegida pelo Windows e nada de configuração dentro da pasta do programa. O código compila com o SDK do .NET 10. A parte de implantação (runtime, firewall do SQL Server, teste em máquina limpa) está em instalador .NET: o que o setup não faz no cliente.

Três sinais de que o app não está pronto para instalar

  • Connection string no arquivo de configuração do build. Tudo o que está em appsettings.json ou App.config vai igual para todos os clientes, com o servidor, o usuário e muitas vezes a senha de quem desenvolveu.
  • O programa grava ao lado do .exe. Instalado em C:\Program Files, ele não tem permissão de escrita ali quando roda como usuário comum. No seu computador funciona porque você é administrador ou roda pela pasta do projeto.
  • Senha em texto puro. Qualquer pessoa que abrir o arquivo de configuração vê a senha do banco da loja.

Configuração do cliente, criada no primeiro uso

A configuração de cada loja (servidor, banco, usuário, senha) não pode vir do build. Ela é perguntada na primeira execução e gravada numa pasta do usuário. O Environment.SpecialFolder.LocalApplicationData aponta para C:\Users\<usuário>\AppData\Local, onde o programa sempre pode escrever e que o MSI não apaga numa atualização, porque não foi ele que criou o arquivo.

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Data.SqlClient;

namespace CaixaFacil;

// Configuração da loja, criada no primeiro uso e guardada fora da pasta do programa.
public sealed class ConfigLocal
{
    public string Servidor { get; set; } = "";      // ex.: ESCRITORIO-01\SQLEXPRESS
    public string Banco { get; set; } = "CaixaFacil";
    public string Usuario { get; set; } = "";
    public string SenhaProtegida { get; set; } = ""; // DPAPI, em Base64

    // Pequena "entropia" própria do app: outro programa do mesmo usuário
    // não consegue abrir a senha só chamando Unprotect.
    private static readonly byte[] Entropia = Encoding.UTF8.GetBytes("CaixaFacil.config.v1");

    public static string Caminho => Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "CaixaFacil", "config.json");

    public static ConfigLocal? Carregar()
    {
        if (!File.Exists(Caminho)) return null; // primeiro uso: abrir a tela de configuração
        return JsonSerializer.Deserialize<ConfigLocal>(File.ReadAllText(Caminho));
    }

    public void Salvar()
    {
        Directory.CreateDirectory(Path.GetDirectoryName(Caminho)!);
        File.WriteAllText(Caminho, JsonSerializer.Serialize(this,
            new JsonSerializerOptions { WriteIndented = true }));
    }

    public void DefinirSenha(string senha) =>
        SenhaProtegida = Convert.ToBase64String(ProtectedData.Protect(
            Encoding.UTF8.GetBytes(senha), Entropia, DataProtectionScope.CurrentUser));

    public string MontarConnectionString()
    {
        var senha = Encoding.UTF8.GetString(ProtectedData.Unprotect(
            Convert.FromBase64String(SenhaProtegida), Entropia, DataProtectionScope.CurrentUser));
        return new SqlConnectionStringBuilder
        {
            DataSource = Servidor,
            InitialCatalog = Banco,
            UserID = Usuario,
            Password = senha,
            Encrypt = SqlConnectionEncryptOption.Mandatory,
            ConnectTimeout = 5
        }.ConnectionString;
    }
}

O DataSource recebe o que a pessoa digitou na tela de primeiro uso, como ESCRITORIO-01\SQLEXPRESS. O SqlConnectionStringBuilder do Microsoft.Data.SqlClient (versão 6.1.7 no teste) monta a string sem concatenação, então um ; na senha não quebra nada. A conexão exige criptografia, que é o padrão do SqlClient desde a versão 4: se o SQL Server da loja usa um certificado autoassinado, a conexão vai falhar até alguém instalar um certificado confiável no servidor. Resolver com TrustServerCertificate=true funciona, mas desliga a verificação do certificado.

A senha protegida pelo Windows

O ProtectedData usa a DPAPI, a API de proteção de dados do próprio Windows, e vem no pacote System.Security.Cryptography.ProtectedData (10.0.14 no teste). Não há chave para guardar: o Windows deriva a chave da conta do usuário. O escopo define quem consegue abrir, segundo a documentação do DataProtectionScope:

  • CurrentUser: só processos rodando com o mesmo usuário do Windows. É o que o exemplo usa.
  • LocalMachine: qualquer processo daquele computador. Serve quando vários usuários do Windows usam o mesmo caixa, mas protege bem menos.

Duas consequências práticas. A senha protegida num computador não abre em outro, então não copie o config.json de um caixa para o outro: cada máquina passa pelo primeiro uso. E a DPAPI só existe no Windows; no Linux, a chamada lança PlatformNotSupportedException.

O ponto de entrada decide entre primeiro uso e caixa

namespace CaixaFacil;

static class Program
{
    [STAThread]
    static void Main()
    {
        ApplicationConfiguration.Initialize();
        var config = ConfigLocal.Carregar();
        if (config is null)
        {
            using var tela = new TelaPrimeiroUso();      // pede servidor, usuário e senha,
            if (tela.ShowDialog() != DialogResult.OK) return; // testa a conexão e chama Salvar()
            config = tela.Config;
        }
        Application.Run(new TelaCaixa(config.MontarConnectionString()));
    }
}

// Esqueletos só para o exemplo compilar.
public class TelaPrimeiroUso : Form { public ConfigLocal Config { get; } = new(); }
public class TelaCaixa : Form { public TelaCaixa(string conexao) { Text = "Caixa"; } }

A tela de primeiro uso deve testar a conexão antes de salvar, com uma mensagem que diga o que fazer ("confira o nome do computador do escritório e se ele está ligado"), e não a exceção crua. O mesmo caminho serve para trocar de servidor depois, como em connection string não se troca na tela: apague ou edite o config.json por uma opção de menu protegida, nunca por um campo livre na tela principal.

O que fica no build e o que fica fora

Depois dessa mudança, o appsettings.json que sai do build só tem o que é igual para todos os clientes, como nível de log e endereço do serviço de atualização. A string de desenvolvimento vai para appsettings.Development.json ou para os User Secrets, e você confere que esse arquivo não aparece na pasta publicada. Antes de gerar o MSI, abra a pasta publicada e procure pelo nome do seu computador e pela senha do banco de desenvolvimento. Se aparecer, o instalador vai levar junto.

Com a configuração no AppData e os dados no banco, atualizar é trocar arquivos em Program Files. Mesmo assim, faça o backup do banco antes, como em atualizar o sistema com backup.

O projeto do exemplo (ConfigTeste.csproj, ConfigLocal.cs e Program.cs, mirando net10.0-windows) compilou sem avisos com o SDK 10.0.402. Ele não foi executado, porque a DPAPI exige Windows e o ambiente usado para escrever é Linux.

Avalie este artigo

Referência: https://learn.microsoft.com/pt-br/visualstudio/deployment/installer-projects-net-core?view=vs-2022