Skip to content

Instantly share code, notes, and snippets.

@startergo
Last active May 17, 2026 12:23
Show Gist options
  • Select an option

  • Save startergo/dedbfa9090bef0ea5aaf2582a35fe32a to your computer and use it in GitHub Desktop.

Select an option

Save startergo/dedbfa9090bef0ea5aaf2582a35fe32a to your computer and use it in GitHub Desktop.
Win98 QEMU File Sharing (macOS Host)

Win98 QEMU File Sharing (macOS Host)

File transfer between a Windows 98 QEMU guest and a macOS host using a lightweight Python HTTP server. This approach was necessary because:

  • QEMU's built-in SMB requires /usr/sbin/smbd which is SIP-protected on macOS
  • Homebrew Samba installs as samba-dot-org-smbd, not smbd
  • macOS File Sharing uses SMBv2/3 — Win98 only speaks SMBv1
  • FTP passive mode fails under QEMU SLIRP NAT (guest appears as 127.0.0.1)
  • IE requires a dial-up modem for internet but works fine with LAN IPs over HTTP

Network Layout (QEMU SLIRP)

Address Role
10.0.2.2 macOS host (gateway)
10.0.2.3 DNS server
10.0.2.4 QEMU built-in SMB (unused)
10.0.2.15 Win98 guest

Setup

1. Create the shared folder

mkdir -p ~/shared

2. Ensure network device is present in the Qemu invocation

The following line must be present in the Qemu invocation to give the guest a network card:

-netdev user,id=net0 -device pcnet,rombar=0,netdev=net0 \

No extra options are needed — the plain default is sufficient for HTTP file sharing.

3. Configure IE in Win98

Open Internet Explorer → View → Internet Options → Connections → select "Never dial a connection" → OK.

This allows IE to use the PCnet LAN adapter without requiring a dial-up modem.


Running the File Server

Run this on macOS before or after starting the VM:

python3 -c "
import http.server, os, mimetypes

SHARE = os.path.expanduser('~/shared')

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        # Strip leading slash to get relative path
        rel = self.path.lstrip('/')
        fpath = os.path.realpath(os.path.join(SHARE, rel))

        # Security: prevent path traversal outside SHARE
        if not fpath.startswith(os.path.realpath(SHARE)):
            self.send_response(403)
            self.end_headers()
            return

        # Serve file download
        if os.path.isfile(fpath):
            mime, _ = mimetypes.guess_type(fpath)
            self.send_response(200)
            self.send_header('Content-Type', mime or 'application/octet-stream')
            self.send_header('Content-Disposition', 'attachment; filename=\"' + os.path.basename(fpath) + '\"')
            self.send_header('Content-Length', str(os.path.getsize(fpath)))
            self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
            self.send_header('Pragma', 'no-cache')
            self.send_header('Expires', '0')
            self.end_headers()
            with open(fpath, 'rb') as f:
                self.wfile.write(f.read())
            return

        # Serve directory listing
        if os.path.isdir(fpath):
            entries = sorted(os.listdir(fpath))
            items = ''
            # Back link if not at root
            if rel:
                parent = '/'.join(rel.rstrip('/').split('/')[:-1])
                items += '<li><a href=\"/' + parent + '\">[..]</a></li>'
            for e in entries:
                epath = os.path.join(fpath, e)
                href = '/' + (rel + '/' + e).lstrip('/')
                label = e + '/' if os.path.isdir(epath) else e
                items += '<li><a href=\"' + href + '\">' + label + '</a></li>'
            upload_path = '/' + rel
            page = ('<html><body>'
                    '<h2>Browse: /' + rel + '</h2>'
                    '<ul>' + items + '</ul><hr>'
                    '<h2>Upload here</h2>'
                    '<form method=POST action=\"' + upload_path + '\" enctype=multipart/form-data>'
                    '<input type=file name=f>'
                    '<input type=submit value=Upload>'
                    '</form></body></html>')
            self.send_response(200)
            self.end_headers()
            self.wfile.write(page.encode())
            return

        self.send_response(404)
        self.end_headers()

    def do_POST(self):
        rel = self.path.lstrip('/')
        dest_dir = os.path.realpath(os.path.join(SHARE, rel))
        if not dest_dir.startswith(os.path.realpath(SHARE)):
            self.send_response(403)
            self.end_headers()
            return
        ct = self.headers.get('Content-Type', '')
        boundary = ct.split('boundary=')[1].encode()
        length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(length)
        parts = body.split(b'--' + boundary)
        for part in parts:
            if b'filename=' in part:
                fname = part.split(b'filename=\"')[1].split(b'\"')[0].decode()
                # Win98 sends full DOS path e.g. C:\WINDOWS\Desktop\file.txt
                fname = fname.replace('\\\\', '/').split('/')[-1]
                content = part.split(b'\r\n\r\n', 1)[1].rsplit(b'\r\n', 1)[0]
                with open(os.path.join(dest_dir, fname), 'wb') as f:
                    f.write(content)
        self.send_response(200)
        self.end_headers()
        back = '/' + rel
        self.wfile.write(('<html><body>Done! <a href=\"' + back + '\">Back</a></body></html>').encode())

    def log_message(self, format, *args):
        print(format % args)

http.server.HTTPServer(('0.0.0.0', 8080), Handler).serve_forever()
"

Requires Python 3.12 or earlier for cgi module. On Python 3.13+ the cgi module was removed — the server above uses raw multipart parsing instead and works on all versions.


Accessing from Win98

Open Internet Explorer and navigate to:

http://10.0.2.2:8080
  • Download: click any filename in the list
  • Upload: use the file picker at the bottom and click Upload

Notes

  • The shared folder on macOS is ~/shared
  • Files uploaded from Win98 appear immediately in that folder
  • To share a file with Win98, just copy it into ~/shared on macOS
  • The server has no authentication — only accessible from inside the QEMU guest
cat > ~/Win98Share.command << 'SCRIPT'
#!/bin/bash
clear
echo "Win98Share Server"
echo "Shared: ~/shared | URL: http://10.0.2.2:8080"
echo "Ctrl+C to stop"
mkdir -p ~/shared
python3 - << 'EOF'
import http.server, os, mimetypes, zipfile, io
SHARE = os.path.expanduser('~/shared')
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
rel = self.path.lstrip('/')
fpath = os.path.realpath(os.path.join(SHARE, rel))
if not fpath.startswith(os.path.realpath(SHARE)):
self.send_response(403); self.end_headers(); return
if os.path.isfile(fpath):
fname = os.path.basename(fpath)
ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else ''
if fname.count('.') > 1 and ext.isdigit():
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(fpath, fname)
data = buf.getvalue()
zipname = fname.replace('.', '_') + '.zip'
self.send_response(200)
self.send_header('Content-Type', 'application/zip')
self.send_header('Content-Disposition', 'attachment; filename="' + zipname + '"')
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
self.end_headers(); self.wfile.write(data)
else:
mime, _ = mimetypes.guess_type(fpath)
self.send_response(200)
self.send_header('Content-Type', mime or 'application/octet-stream')
self.send_header('Content-Disposition', 'attachment; filename="' + fname + '"')
self.send_header('Content-Length', str(os.path.getsize(fpath)))
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
self.end_headers()
with open(fpath, 'rb') as f: self.wfile.write(f.read())
return
if os.path.isdir(fpath):
entries = sorted(os.listdir(fpath))
items = ''
if rel:
parent = '/'.join(rel.rstrip('/').split('/')[:-1])
items += '<li><a href="/' + parent + '">[..]</a></li>'
for e in entries:
ep = os.path.join(fpath, e)
href = '/' + (rel + '/' + e).lstrip('/')
label = e + '/' if os.path.isdir(ep) else e
items += '<li><a href="' + href + '">' + label + '</a></li>'
page = ('<html><head><style>body{font-family:Arial;background:#111;color:#ddd;padding:20px}a{color:#00d4aa}ul{list-style:none;padding:0}li{padding:4px 0;border-bottom:1px solid #222}h2{color:#00d4aa}input[type=submit]{background:#00d4aa;border:none;padding:6px 16px;cursor:pointer}</style></head><body>'
'<h2>/' + rel + '</h2><ul>' + items + '</ul><hr>'
'<h3>Upload</h3><form method=POST action="/' + rel + '" enctype=multipart/form-data>'
'<input type=file name=f> <input type=submit value=Upload></form></body></html>')
self.send_response(200); self.end_headers(); self.wfile.write(page.encode())
return
self.send_response(404); self.end_headers()
def do_POST(self):
rel = self.path.lstrip('/')
dest_dir = os.path.realpath(os.path.join(SHARE, rel))
if not dest_dir.startswith(os.path.realpath(SHARE)):
self.send_response(403); self.end_headers(); return
ct = self.headers.get('Content-Type', '')
boundary = ct.split('boundary=')[1].encode()
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
for part in body.split(b'--' + boundary):
if b'filename=' in part:
fname = part.split(b'filename="')[1].split(b'"')[0].decode()
fname = fname.split('/')[-1].split(chr(92))[-1]
content = part.split(b'\r\n\r\n', 1)[1].rsplit(b'\r\n', 1)[0]
with open(os.path.join(dest_dir, fname), 'wb') as f: f.write(content)
print('Uploaded: ' + fname)
self.send_response(200); self.end_headers()
self.wfile.write(('<html><body style="background:#111;color:#ddd;padding:20px">Done! <a style="color:#00d4aa" href="/' + rel + '">Back</a></body></html>').encode())
def log_message(self, fmt, *args): print(fmt % args)
http.server.HTTPServer(('0.0.0.0', 8080), Handler).serve_forever()
EOF
SCRIPT
chmod +x ~/Win98Share.command
open ~/Win98Share.command
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment