Compare commits
2 Commits
ba9536de12
...
b8bd92e3dc
| Author | SHA1 | Date | |
|---|---|---|---|
| b8bd92e3dc | |||
| 2be1fc599a |
59
README.md
59
README.md
@@ -190,6 +190,65 @@ VS Code launch profiles in `.vscode/launch.json`:
|
|||||||
- `RpgRoller: Server + Edge (F5)`
|
- `RpgRoller: Server + Edge (F5)`
|
||||||
- `RpgRoller: Server + Firefox (F5)`
|
- `RpgRoller: Server + Firefox (F5)`
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Deploy to the Linux server with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script publishes the app locally, uploads a release to `/root/docker/rpgroller/releases/<UTC timestamp>`, updates `/root/docker/rpgroller/current`, rebuilds the `rpgroller` image, and recreates the `rpgroller` container. The SQLite database is preserved because the container keeps using the existing bind mount at `/root/docker/rpgroller/data`.
|
||||||
|
|
||||||
|
Reverse proxy requirements for production:
|
||||||
|
|
||||||
|
- Use `rpgroller.franktovar.de` as the only canonical host.
|
||||||
|
- Forward `X-Forwarded-For` and `X-Forwarded-Proto` so ASP.NET Core can mark the session cookie as secure behind TLS termination.
|
||||||
|
- Proxy `/_blazor` with WebSocket upgrade headers.
|
||||||
|
- Proxy `/api/events/state` as Server-Sent Events with buffering disabled, for example:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
server_name rpgroller.franktovar.de;
|
||||||
|
|
||||||
|
location /_blazor {
|
||||||
|
proxy_pass http://127.0.0.1:8082;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/events/state {
|
||||||
|
proxy_pass http://127.0.0.1:8082;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
gzip off;
|
||||||
|
proxy_read_timeout 3600;
|
||||||
|
add_header X-Accel-Buffering no;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8082;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Environment overrides:
|
Environment overrides:
|
||||||
|
|
||||||
- Set `ConnectionStrings__RpgRoller` to point at a custom SQLite database.
|
- Set `ConnectionStrings__RpgRoller` to point at a custom SQLite database.
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTe
|
|||||||
Assert.Equal("alice", registerResult.Username);
|
Assert.Equal("alice", registerResult.Username);
|
||||||
Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2"));
|
var duplicate = await client.PostAsJsonAsync("/api/auth/register",
|
||||||
|
new RegisterRequest("alice", "Password123", "Alice 2"));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
||||||
|
|
||||||
var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123"));
|
var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123"));
|
||||||
@@ -44,4 +45,27 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTe
|
|||||||
var usernames = await GetAsync<IReadOnlyList<string>>(client, "/api/users/usernames");
|
var usernames = await GetAsync<IReadOnlyList<string>>(client, "/api/users/usernames");
|
||||||
Assert.Equal(["amy", "bob", "zoe"], usernames);
|
Assert.Equal(["amy", "bob", "zoe"], usernames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginCookie_IsMarkedSecure_WhenForwardedProtoIsHttps()
|
||||||
|
{
|
||||||
|
using var factory = CreateFactory();
|
||||||
|
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
|
||||||
|
await RegisterAsync(client, "proxy-user", "Password123", "Proxy User");
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new LoginRequest("proxy-user", "Password123"))
|
||||||
|
};
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https");
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(request);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.NotNull(response.Headers.SingleOrDefault(header => header.Key == "Set-Cookie").Value);
|
||||||
|
|
||||||
|
var setCookie = Assert.Single(response.Headers.GetValues("Set-Cookie"));
|
||||||
|
Assert.Contains("rpgroller_session=", setCookie);
|
||||||
|
Assert.Contains("secure", setCookie, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.ResponseCompression;
|
using Microsoft.AspNetCore.ResponseCompression;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using RpgRoller.Api;
|
using RpgRoller.Api;
|
||||||
using RpgRoller.Components;
|
using RpgRoller.Components;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
@@ -7,6 +8,12 @@ using RpgRoller.Hosting;
|
|||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
||||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
|
options.KnownProxies.Clear();
|
||||||
|
});
|
||||||
builder.Services.AddResponseCompression(options =>
|
builder.Services.AddResponseCompression(options =>
|
||||||
{
|
{
|
||||||
options.EnableForHttps = true;
|
options.EnableForHttps = true;
|
||||||
@@ -18,6 +25,7 @@ builder.Services.AddScoped<WorkspaceQueryService>();
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.InitializeRpgRollerState();
|
app.InitializeRpgRollerState();
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
|
||||||
var configuredPathBase = builder.Configuration["PathBase"];
|
var configuredPathBase = builder.Configuration["PathBase"];
|
||||||
if (!string.IsNullOrWhiteSpace(configuredPathBase))
|
if (!string.IsNullOrWhiteSpace(configuredPathBase))
|
||||||
|
|||||||
106
scripts/deploy.sh
Executable file
106
scripts/deploy.sh
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
readonly PROJECT_PATH="${REPO_ROOT}/RpgRoller/RpgRoller.csproj"
|
||||||
|
|
||||||
|
readonly REMOTE_HOST="myvserver"
|
||||||
|
readonly REMOTE_ROOT="/root/docker/rpgroller"
|
||||||
|
readonly REMOTE_RELEASES_DIR="${REMOTE_ROOT}/releases"
|
||||||
|
readonly REMOTE_CURRENT_LINK="${REMOTE_ROOT}/current"
|
||||||
|
readonly REMOTE_DATA_DIR="${REMOTE_ROOT}/data"
|
||||||
|
|
||||||
|
readonly CONTAINER_NAME="rpgroller"
|
||||||
|
readonly IMAGE_NAME="rpgroller"
|
||||||
|
readonly CONTAINER_PORT="8080"
|
||||||
|
readonly HOST_PORT="8082"
|
||||||
|
readonly RELEASE_TIMESTAMP="$(date -u +%Y%m%d%H%M%S)"
|
||||||
|
readonly LOCAL_STAGE_DIR="${REPO_ROOT}/artifacts/deploy/${RELEASE_TIMESTAMP}"
|
||||||
|
readonly LOCAL_PUBLISH_DIR="${LOCAL_STAGE_DIR}/publish"
|
||||||
|
readonly REMOTE_RELEASE_DIR="${REMOTE_RELEASES_DIR}/${RELEASE_TIMESTAMP}"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "${LOCAL_STAGE_DIR}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
require_tool() {
|
||||||
|
local tool_name="$1"
|
||||||
|
if ! command -v "${tool_name}" >/dev/null 2>&1; then
|
||||||
|
printf 'Required tool not found: %s\n' "${tool_name}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
printf 'Deploying release %s\n' "${RELEASE_TIMESTAMP}"
|
||||||
|
|
||||||
|
require_tool dotnet
|
||||||
|
require_tool rsync
|
||||||
|
require_tool ssh
|
||||||
|
|
||||||
|
mkdir -p "${LOCAL_PUBLISH_DIR}"
|
||||||
|
|
||||||
|
printf '1) Publishing app locally...\n'
|
||||||
|
dotnet publish "${PROJECT_PATH}" -c Release -o "${LOCAL_PUBLISH_DIR}"
|
||||||
|
|
||||||
|
cat > "${LOCAL_STAGE_DIR}/Dockerfile" <<'EOF'
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
WORKDIR /app
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV DOTNET_EnableDiagnostics=0
|
||||||
|
EXPOSE 8080
|
||||||
|
COPY publish/ ./
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
ENTRYPOINT ["dotnet", "RpgRoller.dll"]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
printf '2) Preparing remote release directory...\n'
|
||||||
|
ssh "${REMOTE_HOST}" "mkdir -p '${REMOTE_RELEASES_DIR}' '${REMOTE_DATA_DIR}' && test ! -e '${REMOTE_RELEASE_DIR}'"
|
||||||
|
|
||||||
|
printf '3) Uploading release payload...\n'
|
||||||
|
rsync -az --delete "${LOCAL_STAGE_DIR}/" "${REMOTE_HOST}:${REMOTE_RELEASE_DIR}/"
|
||||||
|
|
||||||
|
printf '4) Building image and restarting container on remote host...\n'
|
||||||
|
ssh "${REMOTE_HOST}" "bash -se" <<EOF
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
remote_release_dir='${REMOTE_RELEASE_DIR}'
|
||||||
|
remote_current_link='${REMOTE_CURRENT_LINK}'
|
||||||
|
container_name='${CONTAINER_NAME}'
|
||||||
|
image_name='${IMAGE_NAME}'
|
||||||
|
release_timestamp='${RELEASE_TIMESTAMP}'
|
||||||
|
remote_data_dir='${REMOTE_DATA_DIR}'
|
||||||
|
container_port='${CONTAINER_PORT}'
|
||||||
|
host_port='${HOST_PORT}'
|
||||||
|
|
||||||
|
previous_current_target=''
|
||||||
|
if [ -L "\${remote_current_link}" ]; then
|
||||||
|
previous_current_target="\$(readlink -f "\${remote_current_link}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker build -t "\${image_name}:\${release_timestamp}" -t "\${image_name}:latest" "\${remote_release_dir}"
|
||||||
|
ln -sfn "\${remote_release_dir}" "\${remote_current_link}"
|
||||||
|
|
||||||
|
if docker ps -aq --filter "name=^/\${container_name}\$" | grep -q .; then
|
||||||
|
docker rm -f "\${container_name}" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker run -d \
|
||||||
|
--name "\${container_name}" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p "127.0.0.1:\${host_port}:\${container_port}" \
|
||||||
|
-e ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
-e ASPNETCORE_URLS="http://+:\${container_port}" \
|
||||||
|
-e ConnectionStrings__RpgRoller="Data Source=/app/data/rpgroller.db" \
|
||||||
|
-v "\${remote_data_dir}:/app/data" \
|
||||||
|
"\${image_name}:\${release_timestamp}" >/dev/null; then
|
||||||
|
if [ -n "\${previous_current_target}" ]; then
|
||||||
|
ln -sfn "\${previous_current_target}" "\${remote_current_link}"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
printf '5) Deployment complete.\n'
|
||||||
Reference in New Issue
Block a user