commit cd068f75374b66969cee5b99ff9f4d7093322450 Author: Anthony Zhang Date: Wed Jan 3 19:55:13 2018 -0500 Initial commit diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5845609 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2018 Anthony Zhang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4fd4086 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +Femtoshare +========== + +Ultra simple self-hosted file sharing. All files can be accessed/modified by all users. Don't upload anything secret! + +Quickstart: run `./femtoshare.py`, then visit `http://localhost:8000/` in your web browser. + +Options: + + $ ./femtoshare.py --help + usage: femtoshare.py [-h] [--port PORT] [--public] + + optional arguments: + -h, --help show this help message and exit + --port PORT local network port to listen on + --public listen on remote network interfaces (allows other hosts to see + the website; otherwise only this host can see it) + +Rationale +--------- + +I often need to send/receive files from untrusted computers. Google Drive can be used for receiving, but not sending - I don't care about people seeing the files, but I do care about not revealing my account credentials when logging in to upload files. + +Services like WeTransfer work for sending, but they often have tiny file size limits and long, hard-to-type file URLs, if they work at all. + +I made Femtoshare to fix these issues. It's basically an FTP server that only needs a web browser to browse/download/upload. No file size limits and easily-memorized links, plus no worries about revealing important account credentials! A site-wide password is easily added to keep out the most unsophisticated attackers. + +Deployment +---------- + +Minimal public-facing deployment: `./femtoshare.py --port 1234 --public` is visible at `http://YOUR_HOST:1234`. + +For improved security, authentication, and HTTPS support, we can run the script as a systemd daemon and put it behind an Nginx reverse proxy, configured to use HTTP Basic Authentication. Assuming a fresh Ubuntu 16.04 LTS machine: + +1. SSH into the machine: `ssh ubuntu@ec2-35-166-68-253.us-west-2.compute.amazonaws.com`. +2. Run software updates: `sudo apt-get update && sudo apt-get upgrade`. +3. Harden SSH: in `/etc/ssh/sshd_config`, change `PasswordAuthentication` to `no` and `PermitRootLogin` to `no` and `AllowUsers` to `ubuntu`. Restart `sshd` using `sudo service sshd restart`. +4. Get requirements: `sudo apt-get nginx openssl`. +5. Get the code: `sudo mkdir -p /var/www/femtoshare && cd /var/www/femtoshare && sudo wget https://raw.githubusercontent.com/Uberi/femtoshare/master/femtoshare.py`. +6. Set up restricted user: `sudo adduser --system --no-create-home --disabled-login --group femtoshare` (there is a `nobody` user, but it's better to have our own user in case other daemons also use `nobody`). +7. Set up file storage directory: `sudo install -o femtoshare -g femtoshare -d /var/www/femtoshare/files`. +8. Set up systemd service to start Femtoshare on boot: + + ```bash + sudo tee /lib/systemd/system/femtoshare.service << EOF + [Unit] + Description=Femtoshare + + [Service] + Type=simple + PrivateTmp=yes + User=femtoshare + Group=femtoshare + ExecStart=/var/www/femtoshare/femtoshare.py + Restart=always + RestartSec=5 + + [Install] + WantedBy=multi-user.target + EOF + sudo systemctl daemon-reload + sudo systemctl enable femtoshare.service + ``` + +9. Set up a password file for HTTP Basic Authentication: `echo "user:$(openssl passwd -apr1 -salt 8b80ef96d09ffd0be0daa1202f55bb09 'YOUR_PASSWORD_HERE')" | sudo tee /var/www/femtoshare/.htpasswd` +10. Set up Nginx as a reverse proxy with HTTP Basic Authentication: + + ```bash + sudo tee /etc/nginx/conf.d/femtoshare.conf << EOF + server { + listen 80; + server_name ~.; + + # HTTP Basic Authentication + auth_basic "Log in with username 'user' to access files"; + auth_basic_user_file /var/www/femtoshare/.htpasswd; + + # serve directory listing + location = / { + proxy_pass http://127.0.0.1:8000; + client_max_body_size 1000M; + } + + # serve uploaded file + location ~ /.+ { + root /var/www/femtoshare/files; + } + } + EOF + ``` + +11. Set up HTTPS with Let's Encrypt: `export LC_ALL="en_US.UTF-8"; export LC_CTYPE="en_US.UTF-8"; sudo wget https://dl.eff.org/certbot-auto && sudo chmod a+x certbot-auto && sudo ./certbot-auto --nginx --debug` +12. Set up twice-daily certificate renewal cronjob with Let's Encrypt: add `23 0,12 * * * PATH=/home/ubuntu/bin:/home/ubuntu/.local/bin:/home/ubuntu/bin:/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin /var/www/femtoshare/certbot-auto renew >> /var/www/femtoshare/letsencrypt-renew-certificate.log 2>&1` in the root crontab with `sudo crontab -e` (setting `PATH` is necessary in order to get Nginx config updates to work). +13. Start Femtoshare and Nginx: `sudo systemctl start femtoshare.service` and `sudo service nginx restart`. +14. Allow incoming and outgoing HTTP and HTTPS traffic through the firewall. + +License +------- + +Copyright 2018-2018 [Anthony Zhang (Uberi)](http://anthonyz.ca). + +The source code is available online at [GitHub](https://github.com/Uberi/femtoshare). + +This program is made available under the MIT license. See ``LICENSE.txt`` in the project's root directory for more information. diff --git a/femtoshare.py b/femtoshare.py new file mode 100755 index 0000000..453649e --- /dev/null +++ b/femtoshare.py @@ -0,0 +1,182 @@ +#!/usr/bin/python3 + +""" +Femtoshare +========== + +Ultra simple self-hosted file sharing. All files can be accessed/modified by all users. Don't upload anything secret! + +Quickstart: run `./femtoshare.py`, then visit `http://localhost:8000/` in your web browser. + +See `./femtoshare.py --help` for usage information. See `README.md` for more documentation. +""" + +__author__ = "Anthony Zhang (Uberi)" +__version__ = "1.0.4" +__license__ = "MIT" + +from http.server import BaseHTTPRequestHandler, HTTPServer +import cgi +import os +import re +import html +import urllib.parse +import argparse +from datetime import datetime +from shutil import copyfileobj + +parser = argparse.ArgumentParser() +parser.add_argument("--port", help="local network port to listen on", type=int, default=8000) +parser.add_argument("--public", help="listen on remote network interfaces (allows other hosts to see the website; otherwise only this host can see it)", action="store_true") +parser.add_argument("--files-dir", help="directory to upload/download files from (prefix with # to specify that the path is relative to the Femtoshare executable)", default="#files") +args = parser.parse_args() + +if args.public: + SERVER_ADDRESS = ("0.0.0.0", args.port) +else: + SERVER_ADDRESS = ("127.0.0.1", args.port) +if args.files_dir.startswith("@"): + FILES_DIRECTORY = os.path.join(os.path.dirname(os.path.realpath(__file__)), args.files_dir[1:]) +else: + FILES_DIRECTORY = args.files_dir + +class FemtoshareRequestHandler(BaseHTTPRequestHandler): + server_version = "Femtoshare/{}".format(__version__) + + def do_GET(self): + if self.path == "/": + self.send_directory_listing() + return + + # check requested filename + filename = urllib.parse.unquote(self.path[1:]) + if not self.path.startswith("/") or not self.is_valid_filename(filename): + self.send_error(400, "Invalid file request") + return + self.send_file(filename) + + def do_POST(self): + # check uploaded file (note that files within cgi.FieldStorage don't need to be explicitly closed) + try: + form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={"REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers["Content-Type"]}) + except: + self.send_error(400, "Invalid file upload parameters") + return + + if "delete_name" in form: # file delete + filename = form["delete_name"].value + if not self.is_valid_filename(filename): + self.send_error(400, "Invalid filename for file deletion") + return + + # delete uploaded file from disk + local_path = os.path.join(FILES_DIRECTORY, filename) + os.remove(local_path) + elif "upload_file" in form: # file upload + filename = form["upload_file"].filename + if not self.is_valid_filename(filename): + self.send_error(400, "Invalid filename for file upload") + return + + # store uploaded file to disk + local_path = os.path.join(FILES_DIRECTORY, filename) + with open(local_path, "wb") as f: + copyfileobj(form["upload_file"].file, f) + else: + self.send_error(400, "Invalid file upload parameters") + return + + self.send_directory_listing() + + def send_file(self, file_path, headers_only=False): + try: + f = open(file_path, "rb") + except OSError: + self.send_error(404, "File not found") + return + + try: + file_info = os.stat(f.fileno()) + self.send_response(200) + self.send_header("Content-type", "application/octet-stream") + self.send_header("Content-Length", str(file_info.st_size)) + self.send_header("Last-Modified", self.date_time_string(file_info.st_mtime)) + self.end_headers() + copyfileobj(f, self.wfile) + finally: + f.close() + + def send_directory_listing(self): + table_entries = [] + try: + for dir_entry in os.scandir(FILES_DIRECTORY): + if not dir_entry.is_file(follow_symlinks=False): continue + file_info = dir_entry.stat() + table_entries.append((dir_entry.name, file_info.st_size, datetime.fromtimestamp(file_info.st_mtime))) + except OSError: + self.send_error(500, "Error listing directory contents") + return + + response = ( + "\n" + + "\n" + + " \n" + + " \n" + + " femtoshare\n" + + " \n" + + " \n" + + " \n" + + "

femtoshare

\n" + + "
\n" + + ( + " \n" + + " \n" + + " \n" + + "".join( + " \n".format( + url=urllib.parse.quote(name), name=html.escape(name), size=size, last_modified=last_modified.isoformat() + ) + for name, size, last_modified in table_entries + ) + + " \n" + + "
namesizelast modifiedactions
{name}{size:,}{last_modified}
\n" + if table_entries else + "

(uploaded files will be visible here)

\n" + ) + + " \n" + + "\n" + ) + response_bytes = response.encode("utf8") + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(response_bytes))) + self.end_headers() + self.wfile.write(response_bytes) + + def is_valid_filename(self, filename): + if (os.path.sep is not None and os.path.sep in filename) or (os.path.altsep is not None and os.path.altsep in filename): # check for filesystem separators + return False + if filename in (os.pardir, os.curdir): # check for reserved filenames + return False + if re.match(r"^[\w `!@\$\^\(\)\-=_\[\];',\.]*$", filename) is None: # check for invalid characters + return False + return True + +if __name__ == '__main__': + os.makedirs(FILES_DIRECTORY, exist_ok=True) # ensure files directory exists + server = HTTPServer(SERVER_ADDRESS, FemtoshareRequestHandler) + try: + server.serve_forever() + except KeyboardInterrupt: + pass