Spis treści

  1. Wstęp
  2. Przygotowanie kontenera danych w Azure
  3. Uruchomienie strony w .NET Core
  4. Aplikacja pobierająca certyfikat SSL
  5. Zapychacze, czyli pozostałe interesujące pliki
    1. Nginx - default (jeszcze nie mamy certyfikatu)
    2. Nginx - default (jeśli już posiadamy certyfikat SSL)
    3. Usługa systemd i Kestrel
    4. appsettings.json (strona WWW)
    5. Startup.cs
    6. UseLetsEncryptAzureUpdate.cs

Wstęp

Dobrze jest jeśli strona internetowa, z której korzystamy używa protokołu HTTPS. Problem w tym, że szkoda pieniędzy na certyfikaty SSL najniższego rzędu (DV - Domain Validated). W szczególności gdy jest to zwykle strona do zastosowań prywatnych, na którą czasami trzeba będzie się zalogować i nie chcemy aby hasło było wysyłane w świat otwartym tekstem.

W tym momencie Let's Encrypt jawi się jako rozwiązanie idealne, choć uciążliwe. Idealne bo certyfikat jest za darmo a uciążliwe bo trzeba bo aktualizować co 3 miesiące bo na tyle jest wystawiany.

Jak jednak ułatwić sobie generowanie certyfikatów bez konieczności logowania się każdorazowo na serwerze i uruchamiania Certbota? Otóż usługa działa w oparciu o protokół ACME (Automatic Certificate Management Environment) - w skrócie rzecz ujmując dostajemy plik o konkretnej nazwie i zawartości do wrzucenia na stronę a następnie Let's Encrypt weryfikuje, że jesteśmy właścicielem jeśli sprawdzi, że wskazany plik znajduje się na naszej stronie. W nagrodę otrzymujemy trzymiesięczny certyfikat SSL.

Nikt jednak nie powiedział, że plik musi znajdować się fizycznie na serwerze. Że kontener weryfikacji ACME nie może znajdować się w... chmurze.

Takie rozwiązanie ma co najmniej dwie zalety: a) mamy jeden centralny punkt dla wszystkich serwerów i/lub stron niezależnie od nazwy domeny, gdzie będą wrzucane pliki weryfikacji własności domeny, b) w przypadku posiadania kilku serwerów korzystających z tego samego certyfikatu SSL (ta sama domena WWW) za np. LoadBalancerem czy TrafficManagerem nie ma znaczenia, na który serwer trafi żądanie o nowy certyfikat - aplikacja do zarządzania certyfikatami zawsze sobie poradzi.

UWAGA: Wszystkie linki w domenie letssl.pieszynski.com są podane jako przykładowe i nie muszą odwzorowywać działającej w chwili obecnej strony WWW.

Przygotowanie kontenera danych w Azure

Zgodnie z planem pliki potwierdzenia posiadania domeny z protokołu ACME nie będą znajdowały się fizycznie na dysku dlatego należy utworzyć dla nich miejsce w kontenerze danych w Azure. W tym konkretnym przykładzie konfiguracja wygląda następująco:

Teraz za pomocą aplikacji Microsoft Azure Storage Explorer (lub portalu choć jest to znacznie mniej wygodne) wrzucamy plik testowy w konkretne miejsce - acme-challenge/test (nazwa katalogu jest ważna, a pliku nie). Następnie generujemy ciągi dostępowe do kontenera

Opcja Generate container-level shared access signature URI nie ma tutaj znaczenia.

Aby wygenerowany ciąg w polu URL stał się ciągiem dostępowym, który będzie można umieścić w konfiguracji strony w zmiennej ACME_CONNECTION_STRING należy niestety "trochę" go zmodyfikować. Wynika to z tego, że takiego formatu wymaga Microsoft.WindowsAzure.Storage.

Jeśli powyższy opis wydaje się niejasny dla porównania umieszczam prawdziwy wygenerowany URL dla polityki ro-policy oraz ciąg połączeniowy potrzebny w zmiennej ACME_CONNECTION_STRING

# URL z Microsoft Azure Storage Explorera
https://letsslstore.blob.core.windows.net/cert-container?sv=2017-04-17&si=ro-policy&sr=c&sig=S3WfdM0JHPPDOJI4GP7mAOeWJzKUJa2QJzMgnaFQFzw%3D
# ACME_CONNECTION_STRING
BlobEndpoint=https://letsslstore.blob.core.windows.net;SharedAccessSignature=sv=2017-04-17&si=ro-policy&sr=c&sig=S3WfdM0JHPPDOJI4GP7mAOeWJzKUJa2QJzMgnaFQFzw%3D

Gdyby okazało się, że wygenerowane ciągi dostępowe wyciekły i ktoś niepowołany uzyskał do nich dostęp należy przegenerować domyślne klucze dostępowe key1 i key2 na zakładce Access keys w portalu Azure. Na szczęście jeśli ktoś wykradł klucz dostępowy SAS z naszego serwera to i tak nie jest w stanie cokolwiek zepsuć, bo jest to klucz tylko do odczytu zgodnie z polityką ro-policy. Nawet gdyby rozpoczął procedurę tworzenia nowego certyfikatu w usłudze Let's Encrypt to nie wrzuci do kontenera Azure żadnego pliku potwierdzającego tożsamość a tym samym nie będzie w stanie wygenerować nowego certyfikatu w naszym imieniu.

Dodatkowym ułatwieniem w przypadku potrzeby zmiany wartości zmiennej ACME_CONNECTION_STRING jest fakt, że nie trzeba restartować aplikacji aby zmiana weszła w życie. Wystarczy zmienić wartość w pliku appsettings.json i gotowe. Strona WWW korzysta z nowego klucza SAS.

Uruchomienie strony w .NET Core

W związku z tym, że na platformie Azure pojawiły się znacznie tańsze maszyny do hostowania stron WWW (spadek z ok. €37 do €3 za miesiąc) dlatego tam zostanie umieszczona strona testowa. Cały opis dotyczy maszyny korzystającej z systemu Ubuntu ale nie ma znaczenia czy jest ona w chmurze czy pod biurkiem.

Ważnym aspektem jest instalacja dotnet.exe na maszynie docelowej. Bardzo dobrze jest to opisanie na stronie producenta: Get started with .NET in 10 minutes. W przypadku Azure należy jeszcze pamiętać o umożliwieniu komunikacji ze światem na portach HTTP i HTTPS (zakładka "Networking").

Teraz czas na instalację Nginx. Pełen opis instalacji i zabezpieczania stron opisany jest również na stronie Microsoftu - Set up a hosting environment for ASP.NET Core on Linux with Nginx, and deploy to it. Żeby nie duplikować wszystkiego na mojej stronie umieszczam tylko same konfiguracje Nginxa - przed i po instalacji pierwszego certyfikatu.

W skrócie: gdy nie posiadamy jeszcze pierwszego certyfikatu, należy wystawić swoją stronę tylko na porcie HTTP aby usługa "Let's Encrypt" mogła za pomocą protokołu ACME wygenerować pierwszy certyfikat SSL. Po zainstalowaniu certyfikatu i przejściu na HTTPS, i wyłączeniu protokołu HTTP (wszystkie żądania będą przerzucane do HTTPS) "Let's Encrypt" nie ma problemów z korzystaniem z HTTPSa więc nie trzeba pozostawiać działającego portu HTTP.

Teraz wystarczy przerzucić pliki strony internetowej na serwer do katalogu docelowego oraz skompilować - dotnet build (usługa uruchamiająca nie wykonuje kompilacji). Jest to związane z faktem, że użytkownik www-data domyślnie nie ma dostępu do zapisu do katalogu w którym znajduje się strona (tu /home/przemek/letssl)

Teraz można spokojnie uruchomić usługę i na potwierdzenie, że wszystko działa wchodzimy na stronę i/lub sprawdzamy jej status w systemie.

root@wssl:~# systemctl status kestrel-letssl.service
● kestrel-letssl.service - letssl WWW
   Loaded: loaded (/etc/systemd/system/kestrel-letssl.service; enabled; vendor preset: enabled)
   Active: active (running) since Mon yyyy-MM-dd HH:mm:ss UTC; 30s ago
 Main PID: 37539 (dotnet)
    Tasks: 30 (limit: 19660)
   Memory: 57.0M
      CPU: 1.516s
   CGroup: /system.slice/kestrel-letssl.service
           ├─37539 /usr/bin/dotnet run --no-build
           └─37570 dotnet exec /home/przemek/letssl/bin/Debug/netcoreapp2.0/LenuAzureAspMiddleware.Example.dll

W tym przypadku (strona przykładowa - źródło w Github LenuAzureAspMiddleware.Example) strona składała się tylko z kontrolera ścieżki głównej (http://letssl.pieszynski.com/) zwracającego aktualną godzinę oraz obsługującego tworzenie certyfikatów więc jedyną szansą na sprawdzenie działania protokołu ACME będzie wejście na spreparowany link (do wrzuconego wcześniej do kontenera Azure pliku acme-challenge/test) udający protokół ACME: http://letssl.pieszynski.com/.well-known/acme-challenge/test

Aplikacja pobierająca certyfikat SSL

Teraz wystarczy już tylko skorzystać a mojej aplikacji do generowania certyfikatów - Pieszynski.LenuManager.exe aby wygenerować wymagane certyfikaty.

Aplikacja po skonfigurowaniu wygeneruje następujące pliki:

Pliki przenosimy w odpowiednie miejsce na serwerze, zmieniamy konfigurację Nginxa aby teraz działał tylko po HTTPS, restartujemy Nginxa i strona WWW działa już przy użyciu HTTPSa. Czynność tę będzie trzeba powtórzyć za jakieś 2,5 miesiąca, bo certyfikaty SSL do "Let's Encrypt" są wystawiane tylko 3 miesiące.

Kody źródłowe oraz skompilowana aplikacja znajdują się na GitHub pod adresem pieszynski/letsencrypt-net-update.

Zapychacze, czyli pozostałe interesujące pliki

Konfiguracja przewidziana jest dla domeny letssl.pieszynski.com, która przez rekord CNAME w DNS wskazuje na serwer w Azure costam-costam.cloudapp.azure.com.

Sprawdzenie konfiguracji i restart Nginx

# sprawdzenie konfiguracji
sudo nginx -t

# wczytanie nowej konfiguracji
sudo nginx -s reload

Nginx - default (jeszcze nie mamy certyfikatu)

# plik: /etc/nginx/sites-available/default
#
# Default server configuration
#

server {
    listen 80;
    location / {
        proxy_pass http://localhost:5000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection keep-alive;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Nginx - default (jeśli już posiadamy certyfikat SSL)

#
# plik: /etc/nginx/sites-available/default
#
# Default server configuration
#

upstream letssl {
    server localhost:5000;
}

server {
    listen *:80;
    add_header Strict-Transport-Security max-age=15768000;
    return 301 https://$host$request_uri;
}

server {
    listen *:443    ssl;
    server_name     letssl.pieszynski.com;
    ssl_certificate /etc/nginx/ssl/letssl.pieszynski.com.der.crt;
    ssl_certificate_key /etc/nginx/ssl/letssl.pieszynski.com.key;
    ssl_protocols TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;
    ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
    ssl_ecdh_curve secp384r1;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;
    ssl_stapling on; #ensure your cert is capable
    ssl_stapling_verify on; #ensure your cert is capable

    add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;

    location / {
        proxy_pass http://letssl;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection keep-alive;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Usługa systemd i Kestrel

#
# Plik /etc/systemd/system/kestrel-letssl.service
#
[Unit]
Description=letssl WWW

[Service]
WorkingDirectory=/home/przemek/letssl
ExecStart=/usr/bin/dotnet run --no-build
Restart=always
RestartSec=10  # Restart service after 10 seconds if dotnet service crashes
SyslogIdentifier=dotnet-letssl
User=www-data
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false

[Install]
WantedBy=multi-user.target

Uruchomienie i sprawdzanie za pomocą poniższych poleceń.

# jednorazowo, aktywacja usługi
\> systemctl enable kestrel-letssl.service

# start i sprawdzenie statusu
\> systemctl start kestrel-letssl.service
\> systemctl status kestrel-letssl.service

appsettings.json (strona WWW)

{
  "ACME_CONNECTION_STRING": "BlobEndpoint=https://...blob.core.windows.net;Sha...",
  "ACME_CONTAINER": "cert-container",
  ...
}

Startup.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Pieszynski.LenuAzureAspMiddleware;

namespace LenuAzureAspMiddleware.Example
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. 
        //  Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            // DODAĆ:
            services.AddLetsEncryptAzureUpdate(Configuration);

            services.AddMvc();
        }

        // This method gets called by the runtime. 
        //  Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            // DODAĆ:
            app.UseLetsEncryptAzureUpdate();

            app.UseMvc();
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .Build();
    }
}

UseLetsEncryptAzureUpdate.cs

Aby skorzystać z poniższego kodu potrzebne są poniższe NuGety.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

namespace Pieszynski.LenuAzureAspMiddleware
{
    /// <summary>
    /// Opcje konfiguracji: Connection string do kontenera danych Azure
    /// </summary>
    public class LenuOptions
    {
        public string ACME_CONNECTION_STRING { get; set; }
        public string ACME_CONTAINER { get; set; }
    }

    /// <summary>
    /// Funkcje pomocnicze do rejestracji w Startup.cs
    /// </summary>
    public static class LenuRegisterAzureMiddlewareExtensions
    {
        public static void AddLetsEncryptAzureUpdate(this IServiceCollection services,
            IConfiguration config
            )
        {
            services.AddRouting();
            services.AddOptions();
            services.Configure<LenuOptions>(config);
        }

        public static void UseLetsEncryptAzureUpdate(this IApplicationBuilder app)
        {
            app.UseForwardedHeaders(new ForwardedHeadersOptions
            {
                ForwardedHeaders =
                    ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
            });
        }
    }

    /// <summary>
    /// Kontroler serwujący dane z kontenera Azure
    /// </summary>
    public class LenuAzureAcmeController : ControllerBase
    {
        protected const string AcmeChallengeDir = "acme-challenge";

        protected readonly LenuOptions options;
        protected readonly ILogger<LenuAzureAcmeController> logger;
        protected readonly CloudBlobContainer container;

        public LenuAzureAcmeController(IOptionsSnapshot<LenuOptions> optionsAccessor, 
            ILogger<LenuAzureAcmeController> logger
            )
        {
            this.options = optionsAccessor.Value;
            this.logger = logger;

            try
            {
                string connectionString = this.options.ACME_CONNECTION_STRING;
                string acmeContainerName = this.options.ACME_CONTAINER;
                this.container = CloudStorageAccount.Parse(connectionString)
                    .CreateCloudBlobClient()
                    .GetContainerReference(acmeContainerName);
            }
            catch (Exception ex)
            {
                this.logger.LogError(
                    ex, 
                    "Missing 'ACME_CONNECTION_STRING' parameter/app setting"
                    );
                throw;
            }
        }

        [Route(".well-known/acme-challenge/{*fileName}")]
        public virtual async Task<IActionResult> GetFilesAsync(string fileName)
        {
            var data = await this.GetChallengeFileContentAsync(fileName);
            if (string.IsNullOrEmpty(data))
                return this.NotFound("Unable to serve file: " + fileName);

            return Content(data, "text/plain");
        }

        protected async Task<string> GetChallengeFileContentAsync(string fileName)
        {
            string blobName = fileName
                ?.Replace(".", "")
                .Replace("/", "")
                .Replace("\\", "");

            if (string.IsNullOrEmpty(blobName))
                return "";

            try
            {
                string response = await this.container
                    .GetBlockBlobReference($"{AcmeChallengeDir}/{blobName}")
                    .DownloadTextAsync();

                return response;
            }
            catch (Exception ex)
            {
                this.logger.LogError(ex, $"Requested file: {fileName}. " +
                    $"Requested blob: {AcmeChallengeDir}/{blobName}");
                return "";
            }
        }
    }
}