system

Ez File Serve

For some time I’ve been wanting to explore kickstart files for automated server installations. I finally got around to trying it out.

The simplest way to make kickstart files accessible during installation is to serve them over HTTP from a web server.

Plain python

Using Python’s built-in HTTP server module, you can quickly serve files from a directory with a single command. No additional packages or configuration required.

python -m http.server 80

This will serve files from the current directory on the host’s IP address.

My approach

The simple command above wasn’t sufficient for my needs. Since I’ll likely need to upload and replace kickstart files multiple times, I created a more feature-rich solution with both upload and serve capabilities using Python.

First, an important warning:

Do NOT expose to the Internet!

This server code is intentionally simple and lacks security hardening. It’s designed exclusively for local network use.

Contains AI generated code

The implementation below is built with Flask.

File: __init__.py

from flask import Flask, current_app
from dotenv import load_dotenv
from datetime import datetime
import os

def create_app(upload_folder_override=None):
    load_dotenv()

    project_root = os.path.dirname(os.path.dirname(__file__))

    template_folder = os.path.join(project_root, 'templates')
    static_folder = os.path.join(project_root, 'static')
    env_upload_folder = os.environ.get('UPLOAD_FOLDER', 'uploads')
    if os.path.isabs(env_upload_folder):
        upload_folder = env_upload_folder
    else:
        upload_folder = os.path.join(project_root, env_upload_folder)
    
    # Use override if provided
    if upload_folder_override:
        upload_folder = upload_folder_override

    os.makedirs(upload_folder, exist_ok=True)

    app = Flask(__name__,
        template_folder=template_folder,
        static_folder=static_folder,
        )

    app.config['UPLOAD_FOLDER'] = upload_folder
    app.secret_key = os.environ.get('SECRET_KEY', 'defaultsecret')
    app.config['UPLOAD_PASSWORD'] = os.environ.get('UPLOAD_PASSWORD', 'password')
    app.config['ADMIN_PASSWORD'] = os.environ.get('ADMIN_PASSWORD', 'password')
    app.config['UPLOAD_AUTH_REQUIRED'] = os.getenv('UPLOAD_AUTH_REQUIRED', 'true').lower() == 'true'
    app.config['ADMIN_AUTH_REQUIRED'] = os.getenv('ADMIN_AUTH_REQUIRED', 'true').lower() == 'true'
    app.config['HOSTS'] = os.getenv('HOSTS')
    app.config['ENABLE_ADMIN'] = os.getenv('ENABLE_ADMIN', 'true').lower() == 'true'

    @app.context_processor
    def inject_year():
        current_year = datetime.now().year
        start_year = int(os.environ.get('START_YEAR', 2025))
        if current_year > start_year:
            display_year = f"{start_year}-{current_year}"
        else:
            display_year = str(start_year)
        return dict(display_year=display_year)

    @app.context_processor
    def inject_config():
        return dict(config=current_app.config)


    from .routes import bp as routes_bp
    app.register_blueprint(routes_bp)

    from .errors import bp as errors_bp
    app.register_blueprint(errors_bp)

    return app

The routes are defined in a separate file.

File: routes.py

import os
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_from_directory, current_app, abort
from urllib.parse import quote, unquote
from .utils import delete_file, list_uploaded_files, is_safe_filename

bp = Blueprint('routes', __name__)

@bp.route('/')
def index():
    uploaded = request.args.get('uploaded')
    error = request.args.get('error')
    files = list_uploaded_files()
    raw_hosts = current_app.config.get('HOSTS')
    host_list = [h.strip() for h in raw_hosts.split(',') if h.strip()]
    file_links = {
        f: [
            { "url": f"{host}{url_for('routes.uploaded_file', filename=f)}",
            "host": host } 
            for host in host_list
            ]
        for f in files
    }
    return render_template('index.html', file_links=file_links,
                           auth_required=current_app.config.get('UPLOAD_AUTH_REQUIRED', True))

@bp.route('/upload', methods=['POST'])
def upload():
    if current_app.config.get('UPLOAD_AUTH_REQUIRED', True):
        password = request.form.get('upload_password', '')
        if password != current_app.config['UPLOAD_PASSWORD']:
            flash('Unauthorized: incorrect password!', 'error')
            return redirect(url_for('routes.admin'))

    files = request.files.getlist('file')
    uploaded_count = 0

    for file in files:
        if not file or file.filename == '':
            continue
        if not is_safe_filename(file.filename):
            flash(f"Invalid filename: {file.filename}", "error")
            continue
        file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], file.filename))
        uploaded_count += 1
    if uploaded_count == 0:
        flash('No file uploaded', 'warning')
    elif uploaded_count == 1:
        flash('1 file uploaded', 'success')
    elif uploaded_count > 1:
        flash(f'{uploaded_count} files uploaded', 'success')

    return redirect(url_for('routes.admin'))

@bp.route('/files/<path:filename>')
def uploaded_file(filename):
    return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename)

@bp.route('/admin', methods=['GET', 'POST'])
def admin():
    if not current_app.config.get('ENABLE_ADMIN', False):
        abort(404)

    auth_required = current_app.config.get('ADMIN_AUTH_REQUIRED', True)
    upload_auth_required = current_app.config.get('UPLOAD_AUTH_REQUIRED', True)

    if request.method == 'POST':
        if auth_required:
            password = request.form.get('delete_password', '')
            if password != current_app.config['ADMIN_PASSWORD']:
                flash('Unauthorized: incorrect password!', 'error')
                files = list_uploaded_files()
                return redirect(url_for('routes.admin'))

        if 'delete_single' in request.form:
            delete_file(request.form.get('delete_single'))
        elif 'delete_selected' in request.form:
            selected_files = request.form.getlist('selected_files')
            if not selected_files:
                flash('No files selected.', 'error')
            else:
                for f in selected_files:
                    delete_file(f)

        # Redirect after successful POST to avoid resubmission
        return redirect(url_for('routes.admin'))

    # GET request
    files = list_uploaded_files()
    return render_template('admin.html', files=files, 
                        upload_auth_required=upload_auth_required, 
                        admin_auth_required=auth_required)

A few utilities are placed in an additional file.

File: utils.py

from flask import current_app, flash
import os
import re

def delete_file(filename):
    file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
    if os.path.exists(file_path):
        os.remove(file_path)
        flash(f'File {filename} deleted.', 'success')
    else:
        flash(f'File {filename} not found.', 'error')

def list_uploaded_files():
    """Returns a sorted list of uploaded files, excluding .gitkeep."""
    upload_folder = current_app.config['UPLOAD_FOLDER']
    return sorted(
        f for f in os.listdir(upload_folder)
        if f != '.gitkeep'
    )

def is_safe_filename(filename):
    #return '..' not in filename and '/' not in filename and '\\' not in filename
    return bool(re.match(r'^[\w.\- ]+$', filename))

For now I created custom error pages for 404 and 500.

File: errors.py

from flask import Blueprint, render_template

bp = Blueprint('errors', __name__)

@bp.app_errorhandler(404)
def page_not_found(e):
    return render_template('404.html'), 404

@bp.app_errorhandler(500)
def internal_error(e):
    return render_template('500.html'), 500

Conclusion

There you have it. A simple LAN file server for kickstart files with upload and management capabilities.

Source

The source code is available on GitHub if you would like to try it for yourself.