first commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
k8s/
|
||||
.git/
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
.vs/
|
||||
+29
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: tcp-echo
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user