commit 71cd045e967c30ad78b87c0108315a7eaf935401 Author: sparkyxf Date: Wed Sep 9 23:15:49 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb31e63 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +**/bin/ +**/obj/ +k8s/ +.git/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7cc9702 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Normalize line endings: store everything as LF in the repository. +* text=auto eol=lf + +# Windows-only scripts keep CRLF on checkout +*.ps1 text eol=crlf +*.cmd text eol=crlf +*.bat text eol=crlf + +# Binary files +*.png binary +*.jpg binary +*.ico binary +*.dll binary +*.exe binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10f9606 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +*.user +.vs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..815c684 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +WORKDIR /src +COPY src/TcpEcho/TcpEcho.csproj TcpEcho/ +RUN dotnet restore TcpEcho/TcpEcho.csproj +COPY src/TcpEcho/ TcpEcho/ +RUN dotnet publish TcpEcho/TcpEcho.csproj -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/runtime:8.0-alpine +ARG VERSION=dev +ARG REVISION=unknown +ARG CREATED="" +# OCI labels: Gitea's package registry displays these on the package page and, when +# image.source matches a repository URL, links the package to that repository. +LABEL org.opencontainers.image.title="tcp-echo-server" \ + org.opencontainers.image.description="Minimal .NET 8 TCP echo server that prefixes every reply with its instance identifier. Deployed as two instances behind Traefik on ports 9098 and 9099." \ + org.opencontainers.image.source="https://gitea.eitczech.com/public/tcp-echo-server" \ + org.opencontainers.image.url="https://gitea.eitczech.com/public/tcp-echo-server" \ + org.opencontainers.image.documentation="https://gitea.eitczech.com/public/tcp-echo-server/src/branch/main/README.md" \ + org.opencontainers.image.vendor="eitczech" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.created="${CREATED}" +WORKDIR /app +COPY --from=build /app . +ENV PORT=9099 +EXPOSE 9099 +USER 1654 +ENTRYPOINT ["dotnet", "TcpEcho.dll"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e699e4a --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# tcp-echo + +Minimal .NET 8 TCP echo server. Every reply is prefixed with the instance identifier +(`INSTANCE_ID` env var, falling back to the hostname), and a `[] connected` line is sent on connect. + +Two instances are deployed behind Traefik: + +| Instance | INSTANCE_ID | External port | +|----------|-------------|---------------| +| a | echo-a | 9098 | +| b | echo-b | 9099 | + +## Build and push + +Requires a prior `docker login gitea.eitczech.com`. + +```powershell +./build-push.ps1 -Tag 1.0.0 # pushes gitea.eitczech.com/public/tcp-echo-server:1.0.0 +./build-push.ps1 -Tag 1.0.0 -AlsoLatest # additionally pushes :latest +``` + +The manifests reference `gitea.eitczech.com/public/tcp-echo-server:latest`. To pin a specific tag either edit +`k8s/instance-a.yaml` / `k8s/instance-b.yaml`, or after `kubectl apply -k` run: + +```sh +kubectl -n tcp-echo set image deploy/tcp-echo-a deploy/tcp-echo-b tcp-echo=gitea.eitczech.com/public/tcp-echo-server:1.0.0 +``` + +If the Gitea registry is private, create a pull secret in the namespace and reference it via `imagePullSecrets`. + +### Package description on Gitea + +The `Dockerfile` sets the standard OCI labels (`org.opencontainers.image.*`). Gitea reads them from the pushed +image and shows them on the package page: + +| Label | Shown as / effect | +|------------------|--------------------------------------------------------------------------------| +| `description` | Package description text | +| `source` | Links the package to the repository with the same URL (repo must exist first) | +| `url`, `documentation`, `licenses`, `authors`, `vendor` | Metadata section on the package page | +| `version`, `revision`, `created` | Filled in by `build-push.ps1` from the tag, git commit and current time | + +To change the description, edit the `LABEL` in `Dockerfile` and rebuild. If the package was pushed before the +repository existed, link it manually: package page -> **Settings** -> **Link to repository**. + +## Deploy + +```sh +kubectl apply -k k8s/ +kubectl -n tcp-echo rollout status deploy/tcp-echo-a deploy/tcp-echo-b +``` + +`30-traefik-entrypoint.yaml` adds `tcpechoa` (9098) and `tcpechob` (9099) entrypoints to the k3s-managed +Traefik (Traefik restarts once). For a Helm-managed Traefik, drop that file from `kustomization.yaml` and run: + +```sh +helm upgrade traefik traefik/traefik -n kube-system --reuse-values -f k8s/traefik-values.yaml +``` + +If Traefik's Service is a LoadBalancer, make sure the cloud/VPS firewall allows TCP 9098 and 9099. + +## Test + +### Linux / macOS + +```sh +$ nc 9098 +[echo-a] connected +hello +[echo-a] hello + +$ nc 9099 +[echo-b] connected +hello +[echo-b] hello +``` + +### Windows (PowerShell) + +Windows has no `nc`. Use the included script, which opens a TCP connection, prints the greeting, +sends a message and prints the echoed reply: + +```powershell +. est-echo.ps1 -Server -Port 9098 +. est-echo.ps1 -Server -Port 9099 -Message "ping" +``` + +Expected output: + +``` +Connecting to :9098 ... +<- [echo-a] connected +-> hello +<- [echo-a] hello +``` + +Just to check the port is reachable (no echo): + +```powershell +Test-NetConnection -ComputerName -Port 9098 +``` + +For an interactive session, `telnet 9098` works if the Telnet Client feature is enabled +(`Enable-WindowsOptionalFeature -Online -FeatureName TelnetClient`), or install `ncat` via `winget install Insecure.Nmap`. + +### Local test with Docker + +```powershell +docker build -t tcp-echo:local . +docker run --rm -d --name tcp-echo-test -e INSTANCE_ID=echo-local -p 9099:9099 tcp-echo:local +. est-echo.ps1 -Server localhost -Port 9099 +docker rm -f tcp-echo-test +``` diff --git a/build-push.ps1 b/build-push.ps1 new file mode 100644 index 0000000..956b18d --- /dev/null +++ b/build-push.ps1 @@ -0,0 +1,58 @@ +<# +.SYNOPSIS + Builds the tcp-echo Docker image and pushes it to the Gitea registry. +.EXAMPLE + .\build-push.ps1 -Tag 1.0.0 + .\build-push.ps1 -Tag 1.0.0 -AlsoLatest +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidatePattern('^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$')] + [string]$Tag, + + # Also tag and push the image as :latest + [switch]$AlsoLatest +) + +$ErrorActionPreference = 'Stop' +$Registry = 'gitea.eitczech.com' +$Repo = "$Registry/public/tcp-echo-server" +$Image = "${Repo}:$Tag" +$Root = $PSScriptRoot + +function Invoke-Checked { + param([string[]]$Cmd) + Write-Host "> $($Cmd -join ' ')" -ForegroundColor Cyan + & $Cmd[0] $Cmd[1..($Cmd.Length - 1)] + if ($LASTEXITCODE -ne 0) { throw "Command failed with exit code ${LASTEXITCODE}: $($Cmd -join ' ')" } +} + +# Metadata baked into OCI labels (shown on the Gitea package page) +$Revision = 'unknown' +if (Get-Command git -ErrorAction SilentlyContinue) { + $r = (& git -C $Root rev-parse --short HEAD 2>$null) + if ($LASTEXITCODE -eq 0 -and $r) { $Revision = $r.Trim() } +} +$Created = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + +Write-Host "Building $Image (revision $Revision)" -ForegroundColor Green +Invoke-Checked @('docker', 'build', '--pull', + '--build-arg', "VERSION=$Tag", + '--build-arg', "REVISION=$Revision", + '--build-arg', "CREATED=$Created", + '-t', $Image, $Root) + +if ($AlsoLatest) { + Invoke-Checked @('docker', 'tag', $Image, "${Repo}:latest") +} + +Write-Host "Pushing $Image" -ForegroundColor Green +Invoke-Checked @('docker', 'push', $Image) + +if ($AlsoLatest) { + Invoke-Checked @('docker', 'push', "${Repo}:latest") +} + +Write-Host "Done: $Image" -ForegroundColor Green +Write-Host "Deploy with: kubectl -n tcp-echo set image deploy/tcp-echo-a deploy/tcp-echo-b tcp-echo=$Image" diff --git a/k8s/00-namespace.yaml b/k8s/00-namespace.yaml new file mode 100644 index 0000000..a19b1d2 --- /dev/null +++ b/k8s/00-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: tcp-echo diff --git a/k8s/30-traefik-entrypoint.yaml b/k8s/30-traefik-entrypoint.yaml new file mode 100644 index 0000000..ba54910 --- /dev/null +++ b/k8s/30-traefik-entrypoint.yaml @@ -0,0 +1,23 @@ +# Traefik must have static entrypoints on 9098/9099 before IngressRouteTCP can use them. +# This is the k3s way (Traefik is installed via the bundled Helm chart). Traefik restarts once when applied. +# If Traefik was installed with plain Helm instead, use k8s/traefik-values.yaml with `helm upgrade`. +apiVersion: helm.cattle.io/v1 +kind: HelmChartConfig +metadata: + name: traefik + namespace: kube-system +spec: + valuesContent: |- + ports: + tcpechoa: + port: 9098 + expose: + default: true + exposedPort: 9098 + protocol: TCP + tcpechob: + port: 9099 + expose: + default: true + exposedPort: 9099 + protocol: TCP diff --git a/k8s/instance-a.yaml b/k8s/instance-a.yaml new file mode 100644 index 0000000..8b91bdc --- /dev/null +++ b/k8s/instance-a.yaml @@ -0,0 +1,87 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tcp-echo-a + namespace: tcp-echo + labels: + app: tcp-echo + instance: a +spec: + replicas: 1 + selector: + matchLabels: + app: tcp-echo + instance: a + template: + metadata: + labels: + app: tcp-echo + instance: a + spec: + containers: + - name: tcp-echo + image: gitea.eitczech.com/public/tcp-echo-server:latest + imagePullPolicy: Always + env: + - name: PORT + value: "9098" + - name: INSTANCE_ID + value: "echo-a" + ports: + - name: tcp + containerPort: 9098 + protocol: TCP + readinessProbe: + tcpSocket: + port: tcp + initialDelaySeconds: 2 + periodSeconds: 5 + livenessProbe: + tcpSocket: + port: tcp + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 20m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1654 + runAsGroup: 1654 + capabilities: + drop: ["ALL"] +--- +apiVersion: v1 +kind: Service +metadata: + name: tcp-echo-a + namespace: tcp-echo +spec: + type: ClusterIP + selector: + app: tcp-echo + instance: a + ports: + - name: tcp + port: 9098 + targetPort: tcp + protocol: TCP +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRouteTCP +metadata: + name: tcp-echo-a + namespace: tcp-echo +spec: + entryPoints: + - tcpechoa + routes: + - match: HostSNI(`*`) + services: + - name: tcp-echo-a + port: 9098 diff --git a/k8s/instance-b.yaml b/k8s/instance-b.yaml new file mode 100644 index 0000000..a3ad012 --- /dev/null +++ b/k8s/instance-b.yaml @@ -0,0 +1,87 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tcp-echo-b + namespace: tcp-echo + labels: + app: tcp-echo + instance: b +spec: + replicas: 1 + selector: + matchLabels: + app: tcp-echo + instance: b + template: + metadata: + labels: + app: tcp-echo + instance: b + spec: + containers: + - name: tcp-echo + image: gitea.eitczech.com/public/tcp-echo-server:latest + imagePullPolicy: Always + env: + - name: PORT + value: "9099" + - name: INSTANCE_ID + value: "echo-b" + ports: + - name: tcp + containerPort: 9099 + protocol: TCP + readinessProbe: + tcpSocket: + port: tcp + initialDelaySeconds: 2 + periodSeconds: 5 + livenessProbe: + tcpSocket: + port: tcp + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 20m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1654 + runAsGroup: 1654 + capabilities: + drop: ["ALL"] +--- +apiVersion: v1 +kind: Service +metadata: + name: tcp-echo-b + namespace: tcp-echo +spec: + type: ClusterIP + selector: + app: tcp-echo + instance: b + ports: + - name: tcp + port: 9099 + targetPort: tcp + protocol: TCP +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRouteTCP +metadata: + name: tcp-echo-b + namespace: tcp-echo +spec: + entryPoints: + - tcpechob + routes: + - match: HostSNI(`*`) + services: + - name: tcp-echo-b + port: 9099 diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..b12fc93 --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - 00-namespace.yaml + - 30-traefik-entrypoint.yaml + - instance-a.yaml + - instance-b.yaml diff --git a/k8s/traefik-values.yaml b/k8s/traefik-values.yaml new file mode 100644 index 0000000..9c1d90f --- /dev/null +++ b/k8s/traefik-values.yaml @@ -0,0 +1,14 @@ +# For non-k3s installs: helm upgrade traefik traefik/traefik -n --reuse-values -f k8s/traefik-values.yaml +ports: + tcpechoa: + port: 9098 + expose: + default: true + exposedPort: 9098 + protocol: TCP + tcpechob: + port: 9099 + expose: + default: true + exposedPort: 9099 + protocol: TCP diff --git a/src/TcpEcho/Program.cs b/src/TcpEcho/Program.cs new file mode 100644 index 0000000..107adf3 --- /dev/null +++ b/src/TcpEcho/Program.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +var port = int.TryParse(Environment.GetEnvironmentVariable("PORT"), out var p) ? p : 9099; +var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID") is { Length: > 0 } id + ? id + : Environment.MachineName; + +var listener = new TcpListener(IPAddress.Any, port); +listener.Start(); +Console.WriteLine($"TCP echo server '{instanceId}' listening on 0.0.0.0:{port}"); + +using var cts = new CancellationTokenSource(); +AppDomain.CurrentDomain.ProcessExit += (_, _) => cts.Cancel(); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + +try +{ + while (!cts.IsCancellationRequested) + { + var client = await listener.AcceptTcpClientAsync(cts.Token); + _ = HandleClientAsync(client, instanceId, cts.Token); + } +} +catch (OperationCanceledException) { } +finally +{ + listener.Stop(); + Console.WriteLine("Shutting down."); +} + +static async Task HandleClientAsync(TcpClient client, string instanceId, CancellationToken ct) +{ + var remote = client.Client.RemoteEndPoint?.ToString() ?? "unknown"; + Console.WriteLine($"Connected: {remote}"); + try + { + using (client) + { + var stream = client.GetStream(); + + // Greeting so the caller knows which instance it reached even before sending anything. + await stream.WriteAsync(Encoding.UTF8.GetBytes($"[{instanceId}] connected\n"), ct); + + var prefix = Encoding.UTF8.GetBytes($"[{instanceId}] "); + var buffer = new byte[4096]; + var reply = new byte[prefix.Length + buffer.Length]; + prefix.CopyTo(reply, 0); + int read; + while ((read = await stream.ReadAsync(buffer, ct)) > 0) + { + buffer.AsSpan(0, read).CopyTo(reply.AsSpan(prefix.Length)); + await stream.WriteAsync(reply.AsMemory(0, prefix.Length + read), ct); + } + } + } + catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException) + { + // client went away or shutdown requested + } + Console.WriteLine($"Disconnected: {remote}"); +} diff --git a/src/TcpEcho/TcpEcho.csproj b/src/TcpEcho/TcpEcho.csproj new file mode 100644 index 0000000..936a161 --- /dev/null +++ b/src/TcpEcho/TcpEcho.csproj @@ -0,0 +1,9 @@ + + + Exe + net8.0 + enable + enable + true + + diff --git a/test-echo.ps1 b/test-echo.ps1 new file mode 100644 index 0000000..06192a4 --- /dev/null +++ b/test-echo.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Connects to a tcp-echo instance, prints the greeting, sends a message and prints the reply. +.EXAMPLE + .\test-echo.ps1 -Server sparkyxf.cz -Port 9098 + .\test-echo.ps1 -Server sparkyxf.cz -Port 9099 -Message "ping" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$Server, + + [Parameter(Mandatory = $true, Position = 1)] + [ValidateRange(1, 65535)] + [int]$Port, + + [string]$Message = "hello", + + [int]$TimeoutMs = 5000 +) + +$ErrorActionPreference = 'Stop' + +function Read-Line { + param([System.IO.StreamReader]$Reader) + $line = $Reader.ReadLine() + if ($null -eq $line) { throw "Connection closed by server." } + return $line +} + +$client = New-Object System.Net.Sockets.TcpClient +try { + $client.ReceiveTimeout = $TimeoutMs + $client.SendTimeout = $TimeoutMs + + Write-Host "Connecting to ${Server}:${Port} ..." -ForegroundColor Cyan + if (-not $client.ConnectAsync($Server, $Port).Wait($TimeoutMs)) { + throw "Connect to ${Server}:${Port} timed out after ${TimeoutMs} ms." + } + + $stream = $client.GetStream() + $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8) + $writer = New-Object System.IO.StreamWriter($stream, (New-Object System.Text.UTF8Encoding($false))) + $writer.NewLine = "`n" + $writer.AutoFlush = $true + + Write-Host "<- $(Read-Line $reader)" -ForegroundColor Green # greeting: [instance] connected + Write-Host "-> $Message" + $writer.WriteLine($Message) + Write-Host "<- $(Read-Line $reader)" -ForegroundColor Green # echo: [instance] +} +finally { + $client.Dispose() +}