first commit

This commit is contained in:
2026-09-09 23:15:49 +02:00
commit 71cd045e96
15 changed files with 570 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
**/bin/
**/obj/
k8s/
.git/
+14
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
bin/
obj/
*.user
.vs/
+29
View File
@@ -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"]
+113
View File
@@ -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 `[<id>] 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 <server-ip> 9098
[echo-a] connected
hello
[echo-a] hello
$ nc <server-ip> 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 <server-ip> -Port 9098
. est-echo.ps1 -Server <server-ip> -Port 9099 -Message "ping"
```
Expected output:
```
Connecting to <server-ip>:9098 ...
<- [echo-a] connected
-> hello
<- [echo-a] hello
```
Just to check the port is reachable (no echo):
```powershell
Test-NetConnection -ComputerName <server-ip> -Port 9098
```
For an interactive session, `telnet <server-ip> 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
```
+58
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: tcp-echo
+23
View File
@@ -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
+87
View File
@@ -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
+87
View File
@@ -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
+7
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
# For non-k3s installs: helm upgrade traefik traefik/traefik -n <traefik-ns> --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
+63
View File
@@ -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}");
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
+54
View File
@@ -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] <message>
}
finally {
$client.Dispose()
}