Ensure Docker images ship CA trust and add TLS smoke tests (#6918)
This commit is contained in:
parent
d045e495f8
commit
42ac08c575
72
.github/workflows/docker-ca.yml
vendored
Normal file
72
.github/workflows/docker-ca.yml
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
name: Docker certificate smoke tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docker/**'
|
||||
- 'scripts/test-ca-trust.sh'
|
||||
- 'test/TlsSmoke/**'
|
||||
- '.github/workflows/docker-ca.yml'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docker/**'
|
||||
- 'scripts/test-ca-trust.sh'
|
||||
- 'test/TlsSmoke/**'
|
||||
- '.github/workflows/docker-ca.yml'
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: elsa-server
|
||||
dockerfile: docker/ElsaServer.Dockerfile
|
||||
image: elsa-server-smoke
|
||||
skip_otel: '0'
|
||||
- name: elsa-server-and-studio
|
||||
dockerfile: docker/ElsaServerAndStudio.Dockerfile
|
||||
image: elsa-server-and-studio-smoke
|
||||
skip_otel: '0'
|
||||
- name: elsa-studio
|
||||
dockerfile: docker/ElsaStudio.Dockerfile
|
||||
image: elsa-studio-smoke
|
||||
skip_otel: '0'
|
||||
- name: elsa-server-datadog
|
||||
dockerfile: docker/ElsaServer-Datadog.Dockerfile
|
||||
image: elsa-server-datadog-smoke
|
||||
skip_otel: '1'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Publish TLS smoke test app
|
||||
run: dotnet publish test/TlsSmoke/TlsSmoke.csproj -c Release -o artifacts/tls-smoke
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build ${{ matrix.name }} image
|
||||
run: docker build -f ${{ matrix.dockerfile }} -t ${{ matrix.image }} .
|
||||
|
||||
- name: Run TLS smoke tests
|
||||
env:
|
||||
SKIP_OTEL: ${{ matrix.skip_otel }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TLS_APP_DIR="${{ github.workspace }}/artifacts/tls-smoke"
|
||||
if [ "$SKIP_OTEL" = "1" ]; then
|
||||
scripts/test-ca-trust.sh "${{ matrix.image }}" "$TLS_APP_DIR" -e ELSA_SKIP_OTEL_AUTO=1
|
||||
else
|
||||
scripts/test-ca-trust.sh "${{ matrix.image }}" "$TLS_APP_DIR"
|
||||
fi
|
||||
26
README.md
26
README.md
|
|
@ -39,6 +39,32 @@ By default, you can access http://localhost:13000 and log in with:
|
|||
Password: password
|
||||
```
|
||||
|
||||
### TLS and custom certificate authorities
|
||||
|
||||
All Elsa Docker images now ship with the operating system's certificate authority bundle baked in at build time. This means you can call public HTTPS endpoints such as `https://example.com` without any additional configuration.
|
||||
|
||||
If you need to trust a private or corporate CA, mount the certificate bundle into the container and reference it via `EXTRA_CA_CERT`:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v /path/to/company-ca.crt:/certs/company-ca.crt:ro \
|
||||
-e EXTRA_CA_CERT=/certs/company-ca.crt \
|
||||
elsaworkflows/elsa-server-and-studio-v3:latest
|
||||
```
|
||||
|
||||
On startup, the container copies the certificate into `/usr/local/share/ca-certificates` and runs `update-ca-certificates`, making the trust available to .NET, OpenSSL, curl, and other system components. Multiple certificates can be provided by pointing `EXTRA_CA_CERT` at a directory containing `.crt` or `.pem` files.
|
||||
|
||||
In highly restricted environments where you cannot modify the system trust store, you can instead rely on the standard `SSL_CERT_FILE` or `SSL_CERT_DIR` environment variables:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v /path/to/company-ca-bundle.pem:/certs/custom.pem:ro \
|
||||
-e SSL_CERT_FILE=/certs/custom.pem \
|
||||
elsaworkflows/elsa-server-and-studio-v3:latest
|
||||
```
|
||||
|
||||
> ℹ️ Installing the CA bundle adds roughly 300KB to the Debian-based images. No package managers run at container startup; all trust updates happen immutably at build time or via the mounted certificates shown above.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Documentation](#documentation)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Version: 1
|
||||
# Description: Dockerfile for building and running Elsa Server
|
||||
# Description: Dockerfile for building and running Elsa Server with Datadog and OpenTelemetry auto-instrumentation
|
||||
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build
|
||||
WORKDIR /source
|
||||
|
|
@ -22,36 +22,40 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base
|
|||
WORKDIR /app
|
||||
COPY --from=build /app/publish ./
|
||||
|
||||
# Install Python 3.11
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3.11 \
|
||||
python3.11-dev \
|
||||
libpython3.11 \
|
||||
python3-pip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Install runtime dependencies, including CA certificates.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libpython3.11 \
|
||||
python3.11 \
|
||||
python3.11-dev \
|
||||
python3-pip \
|
||||
unzip \
|
||||
wget \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Set PYTHONNET_PYDLL environment variable
|
||||
ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && apt-get install -y wget unzip curl
|
||||
|
||||
# Set environment variables for OpenTelemetry Auto-Instrumentation
|
||||
ENV OTEL_DOTNET_AUTO_HOME=/otel
|
||||
ENV OTEL_LOG_LEVEL="debug"
|
||||
ENV OTEL_DOTNET_AUTO_HOME=/otel \
|
||||
OTEL_LOG_LEVEL="debug"
|
||||
|
||||
# Download and extract OpenTelemetry Auto-Instrumentation
|
||||
ARG OTEL_VERSION=1.7.0
|
||||
RUN mkdir /otel
|
||||
RUN curl -L -o /otel/otel-dotnet-install.sh https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/v${OTEL_VERSION}/otel-dotnet-auto-install.sh
|
||||
RUN chmod +x /otel/otel-dotnet-install.sh
|
||||
RUN /bin/bash /otel/otel-dotnet-install.sh
|
||||
|
||||
# Provide necessary permissions for the script to execute
|
||||
RUN chmod +x /otel/instrument.sh
|
||||
RUN mkdir -p /otel \
|
||||
&& curl -L -o /otel/otel-dotnet-install.sh "https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/v${OTEL_VERSION}/otel-dotnet-auto-install.sh" \
|
||||
&& chmod +x /otel/otel-dotnet-install.sh \
|
||||
&& /bin/bash /otel/otel-dotnet-install.sh \
|
||||
&& chmod +x /otel/instrument.sh
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
EXPOSE 443/tcp
|
||||
|
||||
# Instrument the application and start it
|
||||
ENTRYPOINT ["/bin/bash", "-c", "source /otel/instrument.sh && dotnet Elsa.Server.Web.dll"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["dotnet", "Elsa.Server.Web.dll"]
|
||||
|
|
|
|||
|
|
@ -19,17 +19,24 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base
|
|||
WORKDIR /app
|
||||
COPY --from=build /app/publish ./
|
||||
|
||||
# Install Python 3.11
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3.11 \
|
||||
python3.11-dev \
|
||||
libpython3.11 \
|
||||
python3-pip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Install runtime dependencies, including CA certificates.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libpython3.11 \
|
||||
python3.11 \
|
||||
python3.11-dev \
|
||||
python3-pip \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Set PYTHONNET_PYDLL environment variable
|
||||
ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
EXPOSE 443/tcp
|
||||
ENTRYPOINT ["dotnet", "Elsa.Server.Web.dll"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["dotnet", "Elsa.Server.Web.dll"]
|
||||
|
|
|
|||
|
|
@ -20,17 +20,24 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base
|
|||
WORKDIR /app
|
||||
COPY --from=build /app/publish ./
|
||||
|
||||
# Install Python 3.11
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3.11 \
|
||||
python3.11-dev \
|
||||
libpython3.11 \
|
||||
python3-pip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Install runtime dependencies, including CA certificates.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libpython3.11 \
|
||||
python3.11 \
|
||||
python3.11-dev \
|
||||
python3-pip \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Set PYTHONNET_PYDLL environment variable
|
||||
ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
EXPOSE 443/tcp
|
||||
ENTRYPOINT ["dotnet", "Elsa.ServerAndStudio.Web.dll"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["dotnet", "Elsa.ServerAndStudio.Web.dll"]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base
|
|||
WORKDIR /app
|
||||
COPY --from=build /app/publish ./
|
||||
|
||||
# Install CA certificates so HTTPS works out of the box.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
EXPOSE 443/tcp
|
||||
ENTRYPOINT ["dotnet", "Elsa.Studio.Web.dll"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["dotnet", "Elsa.Studio.Web.dll"]
|
||||
|
|
|
|||
103
docker/entrypoint.sh
Executable file
103
docker/entrypoint.sh
Executable file
|
|
@ -0,0 +1,103 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$*" >&2
|
||||
}
|
||||
|
||||
normalise_dest_name() {
|
||||
name=$(basename "$1")
|
||||
case "$name" in
|
||||
*.crt|*.pem) printf '%s\n' "$name" ;;
|
||||
*) printf '%s.crt\n' "$name" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_extra_certificates() {
|
||||
cert_source="$1"
|
||||
if [ ! -e "$cert_source" ]; then
|
||||
log "EXTRA_CA_CERT path '$cert_source' does not exist; skipping installation."
|
||||
return
|
||||
fi
|
||||
|
||||
target_root="/usr/local/share/ca-certificates/extra"
|
||||
mkdir -p "$target_root"
|
||||
rm -f "$target_root"/* 2>/dev/null || true
|
||||
|
||||
copied=0
|
||||
if [ -f "$cert_source" ]; then
|
||||
dest_name=$(normalise_dest_name "$cert_source")
|
||||
cp "$cert_source" "$target_root/$dest_name"
|
||||
copied=1
|
||||
elif [ -d "$cert_source" ]; then
|
||||
for file in "$cert_source"/*.crt "$cert_source"/*.pem; do
|
||||
[ -f "$file" ] || continue
|
||||
dest_name=$(normalise_dest_name "$file")
|
||||
cp "$file" "$target_root/$dest_name"
|
||||
copied=1
|
||||
done
|
||||
else
|
||||
log "EXTRA_CA_CERT path '$cert_source' is neither a file nor a directory; skipping installation."
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$copied" -eq 0 ]; then
|
||||
log "No certificate files found at '$cert_source'; skipping installation."
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v update-ca-certificates >/dev/null 2>&1; then
|
||||
if ! update-ca-certificates >/dev/null 2>&1; then
|
||||
update-ca-certificates
|
||||
fi
|
||||
log "Installed custom certificate(s) from '$cert_source'."
|
||||
elif command -v trust >/dev/null 2>&1; then
|
||||
# Shellcheck disable because we intentionally glob.
|
||||
# shellcheck disable=SC2086
|
||||
for cert in "$target_root"/*.crt; do
|
||||
[ -f "$cert" ] || continue
|
||||
trust anchor "$cert"
|
||||
done
|
||||
log "Installed custom certificate(s) using 'trust' utility."
|
||||
else
|
||||
log "No known certificate installation tool found; custom CA may not be applied."
|
||||
fi
|
||||
}
|
||||
|
||||
maybe_instrument_with_otel() {
|
||||
if [ "${ELSA_SKIP_OTEL_AUTO:-0}" = "1" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -z "${OTEL_DOTNET_AUTO_HOME:-}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
instrument_script="${OTEL_DOTNET_AUTO_HOME%/}/instrument.sh"
|
||||
if [ ! -f "$instrument_script" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if ! command -v bash >/dev/null 2>&1; then
|
||||
log "OpenTelemetry auto-instrumentation requested but bash is unavailable; skipping."
|
||||
return
|
||||
fi
|
||||
|
||||
tmp_wrapper="/tmp/elsa-otel-wrapper.sh"
|
||||
cat <<'WRAPPER' > "$tmp_wrapper"
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
. "${OTEL_DOTNET_AUTO_HOME%/}/instrument.sh"
|
||||
exec "$@"
|
||||
WRAPPER
|
||||
chmod +x "$tmp_wrapper"
|
||||
exec "$tmp_wrapper" "$@"
|
||||
}
|
||||
|
||||
if [ -n "${EXTRA_CA_CERT:-}" ]; then
|
||||
install_extra_certificates "$EXTRA_CA_CERT"
|
||||
fi
|
||||
|
||||
maybe_instrument_with_otel "$@"
|
||||
|
||||
exec "$@"
|
||||
95
scripts/test-ca-trust.sh
Executable file
95
scripts/test-ca-trust.sh
Executable file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Usage: $0 <image> <tls_app_dir> [docker-run-arg ...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE="$1"
|
||||
TLS_APP_DIR="$2"
|
||||
shift 2 || true
|
||||
DOCKER_ARGS=("$@")
|
||||
|
||||
SMOKE_DLL=/tls/TlsSmoke.dll
|
||||
PUBLIC_URL="https://example.com"
|
||||
LOCAL_PORT=9443
|
||||
|
||||
run_smoke() {
|
||||
local url="$1"
|
||||
shift
|
||||
docker run --rm \
|
||||
-v "${TLS_APP_DIR}:/tls:ro" \
|
||||
"${DOCKER_ARGS[@]}" \
|
||||
"$@" \
|
||||
"$IMAGE" \
|
||||
dotnet "$SMOKE_DLL" "$url"
|
||||
}
|
||||
|
||||
run_public_test() {
|
||||
echo "[CA] Validating public trust store against ${PUBLIC_URL}" >&2
|
||||
run_smoke "$PUBLIC_URL"
|
||||
}
|
||||
|
||||
start_local_ca_server() {
|
||||
CERT_WORKDIR=$(mktemp -d)
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 -days 2 -nodes -keyout "$CERT_WORKDIR/ca.key" -out "$CERT_WORKDIR/ca.crt" -subj "/CN=ElsaTestCA" >/dev/null 2>&1
|
||||
openssl req -newkey rsa:2048 -nodes -keyout "$CERT_WORKDIR/server.key" -out "$CERT_WORKDIR/server.csr" -subj "/CN=host.docker.internal" >/dev/null 2>&1
|
||||
|
||||
cat <<CERTEXT > "$CERT_WORKDIR/server.ext"
|
||||
subjectAltName = DNS:localhost,DNS:host.docker.internal,IP:127.0.0.1
|
||||
extendedKeyUsage = serverAuth
|
||||
keyUsage = digitalSignature, keyEncipherment
|
||||
CERTEXT
|
||||
|
||||
openssl x509 -req -in "$CERT_WORKDIR/server.csr" -CA "$CERT_WORKDIR/ca.crt" -CAkey "$CERT_WORKDIR/ca.key" -CAcreateserial -out "$CERT_WORKDIR/server.crt" -days 2 -sha256 -extfile "$CERT_WORKDIR/server.ext" >/dev/null 2>&1
|
||||
|
||||
openssl s_server -quiet -accept "$LOCAL_PORT" -www -cert "$CERT_WORKDIR/server.crt" -key "$CERT_WORKDIR/server.key" >/dev/null 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
for _ in {1..20}; do
|
||||
if nc -z localhost "$LOCAL_PORT" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
echo "$CERT_WORKDIR"
|
||||
}
|
||||
|
||||
run_extra_ca_test() {
|
||||
local cert_dir
|
||||
cert_dir=$(start_local_ca_server)
|
||||
trap "kill ${SERVER_PID:-0} >/dev/null 2>&1 || true; rm -rf '$cert_dir'" EXIT
|
||||
|
||||
echo "[CA] Validating EXTRA_CA_CERT flow against local CA" >&2
|
||||
run_smoke "https://host.docker.internal:${LOCAL_PORT}" \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-v "${cert_dir}:/certs:ro" \
|
||||
-e EXTRA_CA_CERT=/certs/ca.crt
|
||||
|
||||
echo "[CA] Validating SSL_CERT_FILE fallback" >&2
|
||||
run_smoke "https://host.docker.internal:${LOCAL_PORT}" \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-v "${cert_dir}:/certs:ro" \
|
||||
-e SSL_CERT_FILE=/certs/ca.crt
|
||||
|
||||
mkdir -p "$cert_dir/dir"
|
||||
cp "$cert_dir/ca.crt" "$cert_dir/dir/custom-ca.crt"
|
||||
openssl rehash "$cert_dir/dir" >/dev/null 2>&1
|
||||
|
||||
echo "[CA] Validating SSL_CERT_DIR fallback" >&2
|
||||
run_smoke "https://host.docker.internal:${LOCAL_PORT}" \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-v "${cert_dir}:/certs:ro" \
|
||||
-e SSL_CERT_DIR=/certs/dir
|
||||
|
||||
kill "${SERVER_PID:-0}" >/dev/null 2>&1 || true
|
||||
wait "${SERVER_PID:-0}" 2>/dev/null || true
|
||||
rm -rf "$cert_dir"
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
run_public_test
|
||||
run_extra_ca_test
|
||||
28
test/TlsSmoke/Program.cs
Normal file
28
test/TlsSmoke/Program.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using System.Net.Http;
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Usage: TlsSmoke <url>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var url = args[0];
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
using var client = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.GetAsync(url, cts.Token);
|
||||
response.EnsureSuccessStatusCode();
|
||||
Console.WriteLine($"Successfully fetched {url} with status {(int)response.StatusCode}.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Request to {url} failed: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
8
test/TlsSmoke/TlsSmoke.csproj
Normal file
8
test/TlsSmoke/TlsSmoke.csproj
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Loading…
Reference in a new issue