Initial created by ChatGPT

This commit is contained in:
Stephen Barriball 2025-08-25 19:56:30 +01:00
commit 36c7aa081b
11 changed files with 618 additions and 0 deletions

View File

@ -0,0 +1,13 @@
# Dockerfile for GotekBackend (ASP.NET Core 8)
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . ./
RUN dotnet publish -c Release -o /out
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /out ./
RUN mkdir -p /app/Images /app/Uploaded
ENV ASPNETCORE_URLS="http://0.0.0.0:8080;https://0.0.0.0:8443"
EXPOSE 8080 8443
ENTRYPOINT ["dotnet", "GotekBackend.dll"]

View File

@ -0,0 +1,319 @@
// Program.cs — ASP.NET Core 8 backend: builds FAT12 on-the-fly (GET) and extracts on POST
using System.Buffers.Binary;
using System.Text;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography.X509Certificates;
var builder = WebApplication.CreateBuilder(args);
// Require client certificates (mTLS). Configure server cert via appsettings.json or env variables.
builder.WebHost.ConfigureKestrel(k =>
{
k.ConfigureHttpsDefaults(o =>
{
o.ClientCertificateMode = Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode.RequireCertificate;
});
});
var app = builder.Build();
// Basic client-cert gate (replace with your CA/allow-list validation)
app.Use(async (ctx, next) =>
{
if (ctx.Connection.ClientCertificate is null)
{
ctx.Response.StatusCode = 401;
await ctx.Response.WriteAsync("client certificate required");
return;
}
await next();
});
var root = app.Environment.ContentRootPath;
var imagesDir = Path.Combine(root, "Images"); // store ONLY floppy images here (per-tag)
var uploadsDir = Path.Combine(root, "Uploaded"); // optional archive of posted volumes
Directory.CreateDirectory(imagesDir);
Directory.CreateDirectory(uploadsDir);
// Load FF.CFG from disk (mounted file). Fallback to a sane default if missing.
string FfCfgText = LoadFfCfg(Path.Combine(root, "FF.CFG")) ??
"host = acorn\r\n" +
"interface = ibmpc\r\n" +
"index-suppression = no\r\n" +
"sound = on\r\n" +
"sound-volume = 19\r\n" +
"display-type = none\r\n";
// GET /api/volume?id=<hex64> -> build tiny FAT12 in memory containing: [<image file>, FF.CFG]
app.MapGet("/api/volume", async ([FromQuery] string? id) =>
{
if (!IsHex64(id)) return Results.BadRequest("id required (hex, up to 16 chars)");
var imgPath = FindImage(imagesDir, id!);
if (imgPath is null) return Results.NotFound($"No image for id {id}");
byte[] payload = await File.ReadAllBytesAsync(imgPath);
string payloadFileName = Path.GetFileName(imgPath);
byte[] fat = BuildFat12TwoFiles(payload, payloadFileName, Encoding.ASCII.GetBytes(FfCfgText));
return Results.File(new MemoryStream(fat), "application/octet-stream",
fileDownloadName: $"{id!.ToLowerInvariant()}.fat.img", enableRangeProcessing: true);
});
// POST /api/volume?id=<hex64> -> receive modified FAT; extract image file; save back to Images/<id>.<ext>
app.MapPost("/api/volume", async ([FromQuery] string? id, HttpRequest req) =>
{
if (!IsHex64(id)) return Results.BadRequest("id required (hex, up to 16 chars)");
var ts = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var uploadedPath = Path.Combine(uploadsDir, $"{id!.ToLowerInvariant()}-{ts}.fat.img");
await using (var fs = File.Create(uploadedPath))
await req.Body.CopyToAsync(fs);
try
{
await using var s = File.OpenRead(uploadedPath);
var (name, data) = ExtractFirstNonCfgRootFile(s);
if (name is null || data is null)
return Results.BadRequest("no floppy image found in volume");
var ext = Path.GetExtension(name);
if (string.IsNullOrEmpty(ext)) ext = ".img";
var outPath = Path.Combine(imagesDir, $"{id.ToLowerInvariant()}{ext.ToLowerInvariant()}");
await File.WriteAllBytesAsync(outPath, data);
return Results.Ok(new { ok = true, saved = outPath });
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "extract failed", detail = ex.Message });
}
});
app.Run();
// ---------- Helpers ----------
static string? LoadFfCfg(string path)
{
try
{
if (File.Exists(path)) return File.ReadAllText(path);
return null;
}
catch { return null; }
}
static bool IsHex64(string? s)
{
if (string.IsNullOrWhiteSpace(s) || s.Length > 16) return false;
foreach (var c in s) if (!Uri.IsHexDigit(c)) return false;
return true;
}
static string? FindImage(string dir, string id)
{
var stem = id.ToLowerInvariant();
var exts = new[] { ".adf", ".img", ".st", ".dsk", ".ima", ".adl" };
foreach (var ext in exts)
{
var p = Path.Combine(dir, stem + ext);
if (File.Exists(p)) return p;
}
var any = Directory.EnumerateFiles(dir)
.FirstOrDefault(f => Path.GetFileNameWithoutExtension(f)
.Equals(stem, StringComparison.OrdinalIgnoreCase));
return any;
}
// Build tiny FAT12 with two root files: payloadName + FF.CFG
static byte[] BuildFat12TwoFiles(byte[] payload, string payloadName, byte[] ffCfg)
{
const int BPS = 512;
const int SPC = 1;
const int RSVD = 1;
const int FATS = 2;
const int ROOT_ENTS = 32;
const byte MEDIA = 0xF8;
int clus = BPS * SPC;
int clPayload = Math.Max(1, (payload.Length + clus - 1) / clus);
int clCfg = Math.Max(1, (ffCfg.Length + clus - 1) / clus);
int clTotal = clPayload + clCfg;
int fatEntries = clTotal + 2;
int fatBytes = (fatEntries * 3 + 1) / 2;
int spFAT = (fatBytes + BPS - 1) / BPS;
int rootSecs = ((ROOT_ENTS * 32) + (BPS - 1)) / BPS;
int dataSecs = clTotal * SPC;
int totalSecs = RSVD + FATS * spFAT + rootSecs + dataSecs;
if (totalSecs >= 0x10000) throw new InvalidOperationException("volume too large");
using var ms = new MemoryStream(totalSecs * BPS);
var bw = new BinaryWriter(ms, Encoding.ASCII, leaveOpen:true);
byte[] bs = new byte[512];
bs[0]=0xEB; bs[1]=0x3C; bs[2]=0x90;
Encoding.ASCII.GetBytes("MSDOS5.0").CopyTo(bs,3);
WriteLE16(bs,11,(ushort)BPS);
bs[13]=(byte)SPC;
WriteLE16(bs,14,(ushort)RSVD);
bs[16]=(byte)FATS;
WriteLE16(bs,17,(ushort)ROOT_ENTS);
WriteLE16(bs,19,(ushort)totalSecs);
bs[21]=MEDIA;
WriteLE16(bs,22,(ushort)spFAT);
WriteLE16(bs,24,32); WriteLE16(bs,26,64);
WriteLE32(bs,28,0);
bs[36]=0x80; bs[38]=0x29; WriteLE32(bs,39,0x1234ABCD);
Encoding.ASCII.GetBytes("GOTEK VOL ").CopyTo(bs,43);
Encoding.ASCII.GetBytes("FAT12 ").CopyTo(bs,54);
bs[510]=0x55; bs[511]=0xAA;
bw.Write(bs);
int fatAligned = spFAT * BPS;
byte[] fat = new byte[fatAligned];
fat[0]=MEDIA; fat[1]=0xFF; fat[2]=0xFF;
ushort firstPayload=2;
ushort firstCfg=(ushort)(firstPayload+clPayload);
for(int i=0;i<clPayload;i++){
ushort cl=(ushort)(firstPayload+i);
ushort val=(ushort)(i==clPayload-1?0x0FFF:cl+1);
SetFAT12(fat,cl,val);
}
for(int i=0;i<clCfg;i++){
ushort cl=(ushort)(firstCfg+i);
ushort val=(ushort)(i==clCfg-1?0x0FFF:cl+1);
SetFAT12(fat,cl,val);
}
for(int f=0;f<FATS;f++) bw.Write(fat);
byte[] root = new byte[rootSecs*BPS];
var n1 = To83(payloadName);
Array.Copy(n1,0,root,0,11);
root[11]=0x20; WriteLE16(root,26,firstPayload); WriteLE32(root,28,(uint)payload.Length);
var n2 = To83("FF.CFG");
Array.Copy(n2,0,root,32,11);
root[32+11]=0x20; WriteLE16(root,32+26,firstCfg); WriteLE32(root,32+28,(uint)ffCfg.Length);
bw.Write(root);
WriteData(bw,payload,clPayload,BPS);
WriteData(bw,ffCfg,clCfg,BPS);
bw.Flush();
return ms.ToArray();
static void WriteLE16(byte[] b,int o,ushort v){ b[o]=(byte)v; b[o+1]=(byte)(v>>8); }
static void WriteLE32(byte[] b,int o,uint v){ b[o]=(byte)v; b[o+1]=(byte)(v>>8); b[o+2]=(byte)(v>>16); b[o+3]=(byte)(v>>24); }
static void SetFAT12(byte[] fat, ushort cluster, ushort value){
int idx = (cluster*3)/2;
if((cluster&1)!=0){
ushort curr = (ushort)(fat[idx] | (fat[idx+1]<<8));
curr = (ushort)((curr & 0x00FF) | ((value & 0x0FFF) << 8));
fat[idx]=(byte)(curr&0xFF); fat[idx+1]=(byte)(curr>>8);
} else {
ushort curr = (ushort)(fat[idx] | (fat[idx+1]<<8));
curr = (ushort)((curr & 0xF000) | (value & 0x0FFF));
fat[idx]=(byte)(curr&0xFF); fat[idx+1]=(byte)(curr>>8);
}
}
static byte[] To83(string name){
name=name.Replace('\\','/'); if(name.Contains('/')) name=name[(name.LastIndexOf('/')+1)..];
var dot=name.LastIndexOf('.');
var basePart=dot>0?name[..dot]:name;
var ext=dot>0?name[(dot+1)..]:"";
basePart=basePart.ToUpperInvariant();
ext=ext.ToUpperInvariant();
if(string.IsNullOrEmpty(basePart)) basePart="FILE";
Span<byte> out11 = stackalloc byte[11]; out11.Fill((byte)' ');
Encoding.ASCII.GetBytes(basePart.Length>8?basePart[..8]:basePart).CopyTo(out11);
Encoding.ASCII.GetBytes(ext.Length>3?ext[..3]:ext).CopyTo(out11[8..]);
return out11.ToArray();
}
static void WriteData(BinaryWriter bw, byte[] data, int clusters, int bps){
int written=0; byte[] pad=new byte[bps];
for(int i=0;i<clusters;i++){
int take=Math.Min(bps, data.Length-written);
if(take>0) bw.Write(data, written, take);
if(take<bps) bw.Write(pad, 0, bps-take);
written += take;
}
}
}
// Minimal FAT12 extractor (first non-FF.CFG file from root)
static (string? name, byte[]? data) ExtractFirstNonCfgRootFile(Stream s)
{
Span<byte> bpb = stackalloc byte[512];
if (s.Read(bpb) != 512) throw new InvalidOperationException("short BPB");
ushort bps = BinaryPrimitives.ReadUInt16LittleEndian(bpb.Slice(11, 2));
byte spc = bpb[13];
ushort rsv = BinaryPrimitives.ReadUInt16LittleEndian(bpb.Slice(14, 2));
byte fats = bpb[16];
ushort rootEnts = BinaryPrimitives.ReadUInt16LittleEndian(bpb.Slice(17, 2));
ushort spFat = BinaryPrimitives.ReadUInt16LittleEndian(bpb.Slice(22, 2));
if (bps != 512) throw new InvalidOperationException("bps!=512 unsupported");
uint rootSecs = (uint)((rootEnts * 32 + (bps - 1)) / bps);
uint firstFatSec = rsv;
uint rootDirSec = rsv + fats * spFat;
uint firstDataSec = rootDirSec + rootSecs;
s.Position = rootDirSec * 512;
byte[] root = new byte[rootSecs * 512];
if (s.Read(root, 0, root.Length) != root.Length) throw new InvalidOperationException("short root");
for (int i = 0; i < root.Length; i += 32)
{
byte first = root[i];
if (first == 0x00) break;
if (first == 0xE5) continue;
byte attr = root[i + 11];
if ((attr & 0x08) != 0) continue; // vol label
if ((attr & 0x10) != 0) continue; // dir
string name = Encoding.ASCII.GetString(root, i, 8).TrimEnd(' ');
string ext = Encoding.ASCII.GetString(root, i + 8, 3).TrimEnd(' ');
string full = ext.Length > 0 ? $"{name}.{ext}" : name;
if (string.Equals(full, "FF.CFG", StringComparison.OrdinalIgnoreCase)) continue;
ushort firstClus = BinaryPrimitives.ReadUInt16LittleEndian(root.AsSpan(i + 26, 2));
uint size = BinaryPrimitives.ReadUInt32LittleEndian(root.AsSpan(i + 28, 4));
s.Position = firstFatSec * 512;
byte[] fat = new byte[spFat * 512];
if (s.Read(fat, 0, fat.Length) != fat.Length) throw new InvalidOperationException("short FAT");
// FAT12 cluster walk
List<uint> clusters = new();
uint cl = firstClus;
while (cl >= 2 && cl < 0xFF8)
{
clusters.Add(cl);
uint idx = (uint)((cl * 3) / 2);
ushort entry = BitConverter.ToUInt16(fat, (int)idx);
uint next = (cl % 2 == 0) ? (uint)(entry & 0x0FFF) : (uint)(entry >> 4);
cl = next;
if (clusters.Count > 65536) break;
}
using var ms = new MemoryStream((int)size);
foreach (var c in clusters)
{
ulong sector = firstDataSec + (ulong)((c - 2) * spc);
s.Position = (long)sector * 512;
byte[] tmp = new byte[spc * 512];
int got = s.Read(tmp, 0, tmp.Length);
if (got <= 0) break;
int copy = (int)Math.Min((long)tmp.Length, (long)size - ms.Length);
ms.Write(tmp, 0, copy);
if (ms.Length >= size) break;
}
return (full, ms.ToArray());
}
return (null, null);
}

View File

@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Kestrel": {
"Certificates": {
"Default": {
"Path": "/certs/server.pfx",
"Password": "${CERT_PASSWORD}"
}
}
}
}

View File

@ -0,0 +1,31 @@
services:
certgen:
image: alpine:3.20
container_name: certgen
working_dir: /work
volumes:
- ./certs:/work
environment:
CERT_PASSWORD: "${CERT_PASSWORD:-changeit}"
command: ["/bin/sh","-c","apk add --no-cache openssl && chmod +x gen-certs.sh && ./gen-certs.sh"]
gotek-backend:
build: .
container_name: gotek-backend
depends_on:
certgen:
condition: service_completed_successfully
ports:
- "8080:8080"
- "8443:8443"
environment:
CERT_PASSWORD: "${CERT_PASSWORD:-changeit}"
ASPNETCORE_URLS: "http://0.0.0.0:8080;https://0.0.0.0:8443"
ASPNETCORE_Kestrel__Certificates__Default__Path: "/certs/server.pfx"
ASPNETCORE_Kestrel__Certificates__Default__Password: "${CERT_PASSWORD:-changeit}"
volumes:
- ./Images:/app/Images
- ./Uploaded:/app/Uploaded
- ./certs:/certs:ro
- ./FF.CFG:/app/FF.CFG:ro
restart: unless-stopped

8
Certificates/cert-gen.sh Normal file
View File

@ -0,0 +1,8 @@
#!/bin/sh
# Generate self-signed development certificate for GotekBackend
# Usage: ./cert-gen.sh [password]
PASS=${1:-changeit}
mkdir -p certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -subj "/CN=GotekBackendDev" -keyout certs/server.key -out certs/server.crt
openssl pkcs12 -export -out certs/server.pfx -inkey certs/server.key -in certs/server.crt -password pass:$PASS
echo "Certificate generated at certs/server.pfx with password '$PASS'"

View File

@ -0,0 +1,14 @@
# Certificate Setup (Dev)
This backend requires **mutual TLS** (client certs). For local development, use the provided script:
```bash
cd certs
./gen-certs.sh # creates CA, server cert (localhost), client cert, and server.pfx
```
Outputs:
- `ca.crt` — CA certificate. Embed this in the ESP32 firmware as `SERVER_CA_PEM`.
- `server.pfx` — for Kestrel (password: `CERT_PASSWORD`, defaults to `changeit`).
- `server.crt` / `server.key` — PEM versions.
- `client.crt` / `client.key` — sample device certificate (PEM) and `client.pfx`.

View File

@ -0,0 +1,77 @@
#!/bin/sh
set -e
# Defaults
: "${CERT_PASSWORD:=changeit}"
: "${CN_SERVER:=localhost}"
: "${CN_CLIENT:=esp32-device}"
echo "==> Generating development CA, server, and client certificates (for mTLS)"
echo " Password for server.pfx: ${CERT_PASSWORD}"
# Clean previous
rm -f ca.key ca.crt server.key server.csr server.crt server.pfx client.key client.csr client.crt client.pfx \
client.pem client.key.pem server.pem server.key.pem server-chain.crt
# 1) CA (self-signed)
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -subj "/CN=Dev CA" -out ca.crt
# 2) Server key + CSR with SANs (localhost + 127.0.0.1)
cat > server.cnf <<EOF
[req]
default_bits = 2048
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn
[dn]
CN = ${CN_SERVER}
[req_ext]
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1
EOF
openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr -config server.cnf
# 2b) Sign server CSR with CA
cat > ca.cnf <<EOF
[ca]
default_ca = CA_default
[CA_default]
copy_extensions = copy
EOF
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 825 -sha256 -extfile server.cnf -extensions req_ext
# Export server cert+key to PKCS#12 for Kestrel
openssl pkcs12 -export -out server.pfx -inkey server.key -in server.crt -certfile ca.crt -passout pass:${CERT_PASSWORD}
# PEM variants (optional)
cp server.crt server.pem
cp server.key server.key.pem
cat server.crt ca.crt > server-chain.crt
# 3) Client key/cert for device testing
openssl genrsa -out client.key 2048
openssl req -new -key client.key -out client.csr -subj "/CN=${CN_CLIENT}"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 825 -sha256
# PKCS#12 for client (optional)
openssl pkcs12 -export -out client.pfx -inkey client.key -in client.crt -certfile ca.crt -passout pass:${CERT_PASSWORD}
# Convenience PEM for ESP32 (paste into firmware or convert as needed)
cp client.crt client.pem
echo "==> Done."
echo "Artifacts created:"
echo " - ca.crt (CA certificate to trust on ESP32)"
echo " - server.pfx (for Kestrel, protected by CERT_PASSWORD)"
echo " - server.crt/server.key (PEM)"
echo " - client.crt/client.key (PEM) and client.pfx (optional)"

View File

@ -0,0 +1,93 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
>>
endobj
7 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20250825143255+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20250825143255+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
8 0 obj
<<
/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages
>>
endobj
9 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2431
>>
stream
Gat=,CMt+a')eD/;4i#O"9UGf^g?E2*8BO8f1B(#dJ\[()J0oOG[@kk#K$H4pJfIk_66BA&i0'Wa/N"8WpS47+4kss$.0!MqD4ICO8LlG,A0FD4l,u'rd<m0:JRcDEA*jHoO<\:E!*;RN>6A*IR5:23M1Opr:VB+4LWY0H/q`RlG.kuL,pD4,9B&PiM/pgq?Ph/k!P2A;C23*$(@p]]*QY!q;9;D:[W2e$q=Q;Zh&;ee4tmLICJB7/tB1.WdH#Ms5A#;otYF=IWB3d4DuNF#C)]jq1(9-i!_.r2Q`"SQ(%8!<0BfoMaX@1;a=d2^Pj:`cpf9k1/];^b41%G+:U5\,,l[S')pCfc4jTjOO1JULH;]c3mR<o5`q3k6)mISR;Q[R#C8H^n#S%T*4dqFRVJh\5?j?)B5mJ`Fubd<jgZpWfl<?k'VoiYQoeYmrPm6-#1-FO29[s4Uk(6j<^k@BG-?K[(D05`MZi%2?24J2Qc2CFT.mM9Gh,T!hH%+pnG8&O'6<=Akj;bHF5$7Hd$b^s;iHc.ME!:F9=p]\;PZ&V$m*nB//7-SQY+us0\^+8Kk?^8_A#:p3\@p$.%)!Y;:`\'m-^a[$8do*-3h@Bl3N/?D=o2%NP4:d9KKp*rfg!LP"=elOX8H*&uk;OAZ%!dfn)naB5`JZR_7@3\-P<:eU9;lO59!.i+bm"SB2M]:^=S#h[sbgRC'osd74>i%s$>Vlhk*-rS,?C\YP91'd^(M;\.'.*dF4Y`\:OH`*(e]TmCKR%qIPK["RSq.Ih$o1d>+tY'bd.#J/2mcLpO6@iXs?jebk?*["Djrc:Qj@t^_<ZQN^].SFT76%M#@p?W6*A0csL(><;Ur6$!uOQ$-'in+gGV"u3#+h96-9!V!j-(M@,h^.g1W$"JAE`UMt#8J"=_RPC5aUp$Bp@><Gl!?r9;Ara^<XuQ[<sqG:$&*1a\E/I$ksbZmfTt_%(eTL[_H<Pi.29oI7,'VZ<XtXNkDm4^q0VBX43&s>a''@<j16(r;>I(tn9=dsce4U7PsMg[;^BpL[?<f%"tbd"IbOV#;'+BFabCW7lSlsO4k6meU]n@Z6!P.fo<UDU5iVH+=lBD@M0QiYi:89a?Ah\_.O@C9@(P\YS!b1V__aP.ce&V@96GebYWq*87W"Y<>'J)NaTgYVi%&:)i'Mt_+Gp14E]b,5h`-RF4@ab&T]Hmh)j6HX!,p68JI8k/Od2`DH+%YMkF=uFP-Hj!2g&djT"LpfUZ(8MFm*oS^ZF=aGZB)q*r8)7T,/llEJ-*<s,ap^"PZV41P<4!as,;@XK7qm28DQ3qXu(M+LJLj\(5#.GLNB.F(LuCW,/\ApV&fGF#!-[0sR)t\8Iq.DLg95Cl38%>[MV\BWH%2.'Q#fGMEXgidg:$&eQoBcnrN9N51Zp#q!]nPKp&ibOg<T=@pjZm:;T--aQh[=;AGOkZTd(rS2n9](uC(mL,3%$Y+lYait%WQ"6tIl)$+YF5cbsFc4>K=C'@%qFtk9/:0[YBft[_+A4g[aTNPlB\1+S>O_J7/fRM"8[n>h:\L7'Mec:gLfms)X_2X#Is^Q]/g84&ks!j/d_(#.p/?+G91=068CeEhDbRKpl_f0_88/I'X!j,>3pM]5#KBogf-W0T9>ROFf.Zgj-I[)AG+B*JhWGFS*_"t@>`>6X"3[dZ_2@iY9NssaFN)@0R=]6C*19OmT,LAt([jbn^%&$:lOkJT>"/AI&C)j]V7P?s.$TV>3D3<#Q4X+p^0Nns?.P<@-dmln>tb\+Ph@T%Zq6mioN(m'o<rNNF@7!%2k-Jh@Eai7Z5h*.(86]r+'D@>+l.ceeh)=rS6,eF+;e`1)+'jSb[j5]gl]E&(1m9LAGT#3oVSfAUNc^<b'8>tjc&BF>;<YN5<i+@a"JJ`HIWpTH*ZY+U23>=Z[n#7XD9bf;[tjEO(jV%.a-%u_3Uef"KGKWTqFF0#n&Xeq@'BtKUp4i\u1bjP%mer03jtUdu*HuD$u?0k6jY&ib@kmm,5m$"jE3(-uj'6&)@M>Zk7iR;.`^WN/CUdop0q(WHYnmPEd_;L0N5(\,d*`'[sm?JG4QbrHZpUe'Vi2?!3E:o40.-,k31LQId%e(nW&u6_RX&3MVQ6&._4P/"mH<bcT<2Wg+D;,I+T(UP2#pLK`^@rUmnpY[JjA<D$9EAneI-Woo^S`pDGZQ*]5H)+f*A>B\.Hj]M;(gZVPKF`R'(8fp@nb%NUgJ+5Xh@iEZKBG\,-ffK62EkAYk3..fgMU3F(fSYlrIon$sN5:;ZS5:)Mc#*g5OXdgc4:em6.0K#%eUs=Vrouf:O%_]GC#$JbG\W46`\4G`UUN0,fAYte\?TnRor.gjp&>WST#5b2%XCfjVd.oIT$^;FEDr6`R:s_"lo>Ob*NIGZ[c?YH?63?TkI"r826KeJVsY4+khY-'"If,~>endstream
endobj
10 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 781
>>
stream
Gas1\?#SFN'RfGR30/05K^dAeVEI`_<@:u&aL%:+;b1fA,I*[(P\%UYf*RH#UfCG.-h]^Tk9C#=4G)i>kJ1p?Ii]`r+\WIV]E,kQB&U_4Mr:u6HA(Tlk`&%j,Hfr./dWG\rWlq=PqQmFXomri6Z:B#A.'suXMSeelM!CL]R#<;("2ZqN\I)Y%.PMcHs>LfEBQ`9'-)nROMSW9[)[L$Qq]q1[k\[Ec^,]-hBg%2#X>!TI7Fp.jlGjMlJW%R`C61U[SM>.^s"tu8fNIHFm!)uWnD-t%Ln:_&m7gFjSE8R.,I#imR]2(E96&eSqDJr*Q>H97@L3nTnLL^/QKl\%TsKEbu@8dO:D2AgL^;;$7l.1a'85KrC6a8QEICfB?%Tarmmco1WZ3_f\O^1OgD&&;S82;N\W`%0'9kH_jg\;J(8o'rQ`MfOWQ@$8_V5m,p<\;+^(LTN+(*!Ce@>S'O.D.)a.9@:UX"KA\hq]&:]MB_H>5?iXau]Jk!_je3aI6R?b!HKiDTa\<%HD\FO&lpSRP/\#[eCiDL6p=h;h^&t-)X+B>uReeBG0;%$#\Me"p;^%C@K&B2MPfJ.1hjOR`tH&rHmgl=2,-V$kaMNjB<J<-nUB-*^nEu).IUcN!P0-[9naFUKSU^WZg9[J_o%(Ja5EU:o#U-33TjZ=),?3jF86Z"*c<*L:9j^NDroAs"4NF`8gEP^/I0tu3F`G5+&C:7&D6FtSu'fnQKRW&sa#>TT]R@-h(kPX%.TB`oIP;$5-WI-YV_pNb4EUahFN`><~>endstream
endobj
xref
0 11
0000000000 65535 f
0000000073 00000 n
0000000114 00000 n
0000000221 00000 n
0000000333 00000 n
0000000536 00000 n
0000000740 00000 n
0000000808 00000 n
0000001091 00000 n
0000001156 00000 n
0000003678 00000 n
trailer
<<
/ID
[<9b82c849161aa76ccab15de04da90016><9b82c849161aa76ccab15de04da90016>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 7 0 R
/Root 6 0 R
/Size 11
>>
startxref
4550
%%EOF

30
Documentation/README.md Normal file
View File

@ -0,0 +1,30 @@
# GotekBackend (Docker)
Backend for ESP32-S2/S3 Gotek controller.
- Stores ONLY raw floppy images in `Images/` (e.g. `Images/<id>.adf`).
- Builds a tiny FAT12 volume on-the-fly for GET, with your image + **FF.CFG from disk**.
- Accepts POSTed modified FAT, extracts the floppy image, and overwrites `Images/<id>.<ext>`.
- Requires mutual TLS (client certificate) from the ESP32.
## FF.CFG
Edit `FF.CFG` in the project root. The backend reads it at startup and serves it inside the FAT volume.
Compose mounts it into the container at `/app/FF.CFG` (read-only).
## Run with Docker Compose
```bash
CERT_PASSWORD=changeit docker compose up --build
```
This starts a `certgen` one-shot service that creates a dev CA and server cert, then runs the backend.
## Endpoints
- `GET /api/volume?id=<hex64>` -> returns a FAT12 image (your disk image + FF.CFG)
- `POST /api/volume?id=<hex64>` -> receives FAT12, extracts first non-`FF.CFG` file, saves to `Images/`
## Volumes
- `./Images` -> /app/Images
- `./Uploaded` -> /app/Uploaded
- `./certs` -> /certs (server.pfx + CA + client certs)
- `./FF.CFG` -> /app/FF.CFG (read-only)
## Environment
- `CERT_PASSWORD` (required): password for `/certs/server.pfx`

View File

@ -0,0 +1,5 @@
Conversation transcript summary
- ESP32-S2/S3 firmware with Wi-Fi setup, mTLS, pin-controlled mount/eject, RFID tag ID, /api/status, /api/remount
- Backend stores ONLY images; builds FAT12 on GET and extracts image on POST
- Dockerfile + appsettings.json + docker-compose.yml (with certgen) included
- FF.CFG is loaded from disk and served inside the FAT

11
Frontend/FF.CFG Normal file
View File

@ -0,0 +1,11 @@
# FF.CFG - FlashFloppy configuration (served inside FAT by backend)
interface = ibmpc
host = acorn
index-suppression = no
# Enable speaker / fake disk drive sounds
sound = on
sound-volume = 19
# Disable display devices
display-type = none