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:
This server code is intentionally simple and lacks security hardening. It’s designed exclusively for local network use.
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.
