Rough draft of feature.

This commit is contained in:
Jeromy Streets 2018-05-22 17:11:30 -07:00
parent 4454460785
commit 084257d34d

515
serve.go
View File

@ -1,15 +1,92 @@
package main package main
import ( import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt" "fmt"
"io"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"golang.org/x/crypto/scrypt"
"github.com/howeyc/gopass"
) )
var ( var (
version = "Version 1.1" version = "Version 1.2"
authHelp = `
NAME
static-file-server auth
SYNOPSIS
static-file-server auth [ help | -help | --help ]
static-file-server auth list
static-file-server auth add $username [ $password ]
static-file-server auth update $username [ $password ]
static-file-server auth remove $username
DESCRIPTION
The Static File Server authentication sub-command is used to securely modify
a credential file for use with basic authentication.
DEPENDENCIES
None... not even libc!
ENVIRONMENT VARIABLES
CREDENTIALS
The path to a file that contains valid credentials, or the path to a
file that will have credentials modified. By having this variable set
basic authentication will automatically be used. Credentials must be
added to the file using the 'auth add' command prior to use. If no
credentials are added, then basic authentication will always fail (fails
secure). It is HIGHLY RECOMMENDED to use this with TLS certificates. If
you are not using TLS certificates, don't use credentials that are
important. Username and password are case-sensitive. Variable must be
set to use any authentication sub-commands, with the exception of
requesting help.
COMMANDS
add $username [ $password ]
Add a new credential to the credential file. If the username is not set
or is the same as an existing username, the command will fail. If the
password for a user needs to be updated, use 'auth update'. If the
password is not supplied, it will be requested during execution.
ARGS:
$username: The case-sensitive username to be added.
$password: The case-sensitive password to be associated with the new
username.
help
Prints this help documentation.
list
List all usernames in the credential file.
remove $username
Remove an existing username from the credential file. If the username is
not set or doesn't match an existing username, the command will fail.
ARGS:
$username: The case-sensitive username to be removed.
update $username [ $password ]
Update an existing username with a new password in the credential file.
If the username is not set or doesn't match an existing username, the
command will fail. If a new user needs to be added, use 'auth add'. If
the password is not supplied, it will be requested during execution.
ARGS:
$username: The case-sensitive username to be updated with a new
password.
$password: The case-sensitive passwrod to be associated with the
existing username.
`
help = ` help = `
NAME NAME
@ -19,6 +96,7 @@ SYNOPSIS
static-file-server static-file-server
static-file-server [ help | -help | --help ] static-file-server [ help | -help | --help ]
static-file-server [ version | -version | --version ] static-file-server [ version | -version | --version ]
static-file-server auth [ help | -help | --help ]
DESCRIPTION DESCRIPTION
The Static File Server is intended to be a tiny, fast and simple solution The Static File Server is intended to be a tiny, fast and simple solution
@ -31,6 +109,22 @@ DEPENDENCIES
None... not even libc! None... not even libc!
ENVIRONMENT VARIABLES ENVIRONMENT VARIABLES
CREDENTIALS
The path to a file that contains valid credentials, or the path to a
file that will have credentials modified. By having this variable set
basic authentication will automatically be used. Credentials must be
added to the file using the 'auth add' command prior to use. If no
credentials are added, then basic authentication will always fail (fails
secure). It is HIGHLY RECOMMENDED to use this with TLS certificates. If
you are not using TLS certificates, don't use credentials that are
important. Username and password are case-sensitive.
FAST_AUTH
It is recommended to use CREDENTIALS and not FAST_AUTH. FAST_AUTH is
only provided for users that want to share files for a short time only
using an unimportant password. If CREDENTIALS is set, FAST_AUTH will be
ignored. The value of FAST_AUTH is a colon (:) delimited username and
password (for example: FAST_AUTH=user:password). Username and password
are case-sensitive.
FOLDER FOLDER
The path to the folder containing the contents to be served over The path to the folder containing the contents to be served over
HTTP(s). If not supplied, defaults to '/web' (for Docker reasons). HTTP(s). If not supplied, defaults to '/web' (for Docker reasons).
@ -58,6 +152,19 @@ ENVIRONMENT VARIABLES
start with a forward-slash and NOT end with a forward-slash. If not start with a forward-slash and NOT end with a forward-slash. If not
supplied then no prefix is used. supplied then no prefix is used.
COMMANDS
[No commands supplied]
Serve static files based on passed environment variables. Server will
continue to run until shutdown of the service is requested.
auth
The CREDENTIALS environment variable must be set to use the
authorization command. The authorization command is used to securely
add, modify and remove user credentials for basic authentication. For
more information, run 'static-file-server auth help'.
help
Prints this help documentation.
USAGE USAGE
FILE LAYOUT FILE LAYOUT
/var/www/sub/my.file /var/www/sub/my.file
@ -106,37 +213,293 @@ USAGE
export SHOW_LISTING=false export SHOW_LISTING=false
static-file-server static-file-server
Returns 'NOT FOUND': wget http://my.machine/ Returns 'NOT FOUND': wget http://my.machine/
export CREDENTIALS=credentials.json
export FOLDER=/var/www/sub
export PORT=443
export TLS_CERT=/etc/server/my.machine.crt
export TLS_KEY=/etc/server/my.machine.key
static-file-server auth add 'user' 'pass'
static-file-server auth add 'john' '12345'
static-file-server
Retrieve with:
wget --user 'user' --password 'pass' --auth-no-challenge \
https://my.machine/my.file
wget --user 'john' --password '12345' --auth-no-challenge \
https://my.machine/my.file
export FAST_AUTH=user:pass
export FOLDER=/var/www/sub
export PORT=443
export TLS_CERT=/etc/server/my.machine.crt
export TLS_KEY=/etc/server/my.machine.key
static-file-server
Retrieve with:
wget --user 'user' --password 'pass' --auth-no-challenge \
https://my.machine/my.file
` `
credentials map[string]*credential
envvar struct {
credentialFile string
fastAuth string
folder string
host string
port string
showListing bool
tlsCert string
tlsKey string
urlPrefix string
}
) )
// credential that is stored and used for authentication.
type credential struct {
// Salt (random) for uniquely encrypting each password.
Salt string `json:"salt"`
// Password after it has been encrypted with the salt.
Password string `json:"pass"`
}
// matches the passed uncrypted password against the assigned encrypted
// password. Returns true if the passwords match.
func (c *credential) matches(password string) (matches bool, err error) {
matches = false
var enc string
if enc, err = c.encrypt(password, c.Salt); nil != err {
return
}
matches = enc == c.Password
return
}
// update the encrypted credential password with a new, unencrypted password.
func (c *credential) update(password string) (err error) {
// Create a new salt value.
rawSalt := make([]byte, 24)
var n int
if n, err = io.ReadFull(rand.Reader, rawSalt); nil != err {
return
}
if len(rawSalt) != n {
err = errors.New("failed to create random password salt")
return
}
// Store salt and encrypted password.
c.Salt = base64.StdEncoding.EncodeToString(rawSalt)
c.Password, err = c.encrypt(password, c.Salt)
return
}
// encrypt a password/salt combination to the encrypted password.
func (c *credential) encrypt(password, salt string) (enc string, err error) {
// Decode salt from Base64 to raw bytes.
var rawSalt []byte
if rawSalt, err = base64.StdEncoding.DecodeString(salt); nil != err {
return
}
// Encrypt password to bytes. Encryption values determined by current
// cryptography suggestion.
var rawEnc []byte
if rawEnc, err = scrypt.Key(
[]byte(password), rawSalt, 16384, 8, 1, 32,
); nil != err {
return
}
// Encode as Base64.
enc = base64.StdEncoding.EncodeToString(rawEnc)
return
}
func authAddMain(command string, args []string) (err error) {
if 0 == len(args) || 2 < len(args) {
return fmt.Errorf(
"wrong number of arguments supplied to: '%s add', try '%s help'",
command, command,
)
}
username := args[0]
var password string
if 1 < len(args) {
password = args[1]
}
if err = loadCredentials(envvar.credentialFile); nil != err {
fmt.Printf(
"WARNING: Credential file '%s' doesn't already exist... creating.\n",
envvar.credentialFile,
)
err = nil
}
if _, found := credentials[username]; found {
return fmt.Errorf(
"user '%s' already exists, use 'update' in place of 'add'",
username,
)
}
if 0 == len(password) {
if password, err = getPassword(); nil != err {
return
}
}
cred := &credential{}
if err = cred.update(password); nil != err {
return
}
credentials[username] = cred
return saveCredentials(envvar.credentialFile)
}
func authListMain(command string, args []string) (err error) {
if 0 != len(args) {
return fmt.Errorf(
"'%s list' does not accept any arguments, try '%s help'",
command, command,
)
}
if err = loadCredentials(envvar.credentialFile); nil != err {
return
}
for username := range credentials {
fmt.Println(username)
}
return
}
func authRemoveMain(command string, args []string) (err error) {
if 1 != len(args) {
return fmt.Errorf(
"'%s remove' requires exactly one argument, try '%s help'",
command, command,
)
}
username := args[0]
if err = loadCredentials(envvar.credentialFile); nil != err {
return
}
if _, found := credentials[username]; !found {
return fmt.Errorf("user '%s' doesn't exist", username)
}
delete(credentials, username)
return saveCredentials(envvar.credentialFile)
}
func authUpdateMain(command string, args []string) (err error) {
if 0 == len(args) || 2 < len(args) {
return fmt.Errorf(
"wrong number of arguments supplied to: '%s update', try '%s help'",
command, command,
)
}
username := args[0]
var password string
if 1 < len(args) {
password = args[1]
}
if err = loadCredentials(envvar.credentialFile); nil != err {
return
}
cred, found := credentials[username]
if !found {
return fmt.Errorf(
"user '%s' doesn't exist, use 'add' in place of 'update'",
username,
)
}
if 0 == len(password) {
if password, err = getPassword(); nil != err {
return
}
}
if err = cred.update(password); nil != err {
return
}
return saveCredentials(envvar.credentialFile)
}
func authMain(command string, args []string) {
// Subcommand not supplied. Redirect to help.
if 0 == len(args) {
log.Fatalf(
"no arguments supplied to: '%s', try '%s help'",
command, command,
)
}
subCommand := args[0]
args = args[1:]
if strings.Contains(subCommand, "help") {
fmt.Println(authHelp)
return
}
if 0 == len(envvar.credentialFile) {
log.Fatalln("credential file required but not set")
}
var err error
switch subCommand {
case "add":
err = authAddMain(command, args)
case "list":
err = authListMain(command, args)
case "remove":
err = authRemoveMain(command, args)
case "update":
err = authUpdateMain(command, args)
default:
err = fmt.Errorf(
"unrecognized command '%s %s', try '%s help'",
command, subCommand, command,
)
}
if nil != err {
log.Fatalln(err)
}
}
func main() { func main() {
// Collect environment variables.
envvar.credentialFile = env("CREDENTIALS", "")
envvar.fastAuth = env("FAST_AUTH", "")
envvar.folder = env("FOLDER", "/web") + "/"
envvar.host = env("HOST", "")
envvar.port = env("PORT", "8080")
envvar.showListing = envAsBool("SHOW_LISTING", true)
envvar.tlsCert = env("TLS_CERT", "")
envvar.tlsKey = env("TLS_KEY", "")
envvar.urlPrefix = env("URL_PREFIX", "")
// Evaluate and execute subcommand if supplied. // Evaluate and execute subcommand if supplied.
appName := os.Args[0]
if 1 < len(os.Args) { if 1 < len(os.Args) {
arg := os.Args[1] arg := os.Args[1]
command := fmt.Sprintf("%s %s", appName, arg)
switch { switch {
case strings.Contains(arg, "help"): case strings.Contains(arg, "help"):
fmt.Println(help) fmt.Println(help)
case strings.Contains(arg, "version"): case strings.Contains(arg, "version"):
fmt.Println(version) fmt.Println(version)
case "auth" == arg:
authMain(command, os.Args[2:])
default: default:
name := os.Args[0] log.Fatalf("Unknown argument: %s. Try '%s help'.", arg, appName)
log.Fatalf("Unknown argument: %s. Try '%s help'.", arg, name)
} }
return return
} }
// Collect environment variables.
folder := env("FOLDER", "/web") + "/"
host := env("HOST", "")
port := env("PORT", "8080")
showListing := envAsBool("SHOW_LISTING", true)
tlsCert := env("TLS_CERT", "")
tlsKey := env("TLS_KEY", "")
urlPrefix := env("URL_PREFIX", "")
// If HTTPS is to be used, verify both TLS_* environment variables are set. // If HTTPS is to be used, verify both TLS_* environment variables are set.
if 0 < len(tlsCert) || 0 < len(tlsKey) { if 0 < len(envvar.tlsCert) || 0 < len(envvar.tlsKey) {
if 0 == len(tlsCert) || 0 == len(tlsKey) { if 0 == len(envvar.tlsCert) || 0 == len(envvar.tlsKey) {
log.Fatalln( log.Fatalln(
"If value for environment variable 'TLS_CERT' or 'TLS_KEY' is set " + "If value for environment variable 'TLS_CERT' or 'TLS_KEY' is set " +
"then value for environment variable 'TLS_KEY' or 'TLS_CERT' must " + "then value for environment variable 'TLS_KEY' or 'TLS_CERT' must " +
@ -146,28 +509,80 @@ func main() {
} }
// If the URL path prefix is to be used, verify it is properly formatted. // If the URL path prefix is to be used, verify it is properly formatted.
if 0 < len(urlPrefix) && if 0 < len(envvar.urlPrefix) &&
(!strings.HasPrefix(urlPrefix, "/") || strings.HasSuffix(urlPrefix, "/")) { (!strings.HasPrefix(envvar.urlPrefix, "/") || strings.HasSuffix(envvar.urlPrefix, "/")) {
log.Fatalln( log.Fatalln(
"Value for environment variable 'URL_PREFIX' must start " + "Value for environment variable 'URL_PREFIX' must start " +
"with '/' and not end with '/'. Example: '/my/prefix'", "with '/' and not end with '/'. Example: '/my/prefix'",
) )
} }
// Determine whether basic authentication is needed.
auth := func(handler http.HandlerFunc) http.HandlerFunc {
return handler
}
if 0 < len(envvar.credentialFile) || 0 < len(envvar.fastAuth) {
if 0 < len(envvar.credentialFile) {
if err := loadCredentials(envvar.credentialFile); nil != err {
log.Fatalln(err)
}
} else {
credentials = make(map[string]*credential)
parts := strings.Split(envvar.fastAuth, ":")
if 2 != len(parts) || 0 == len(parts[0]) || 0 == len(parts[1]) {
log.Fatalln(
"'FAST_AUTH' must have exactly one colon (:) to separate " +
"the username from the password (username:password)",
)
}
cred := &credential{}
if err := cred.update(parts[1]); nil != err {
log.Fatalln(err)
}
credentials[parts[0]] = cred
}
auth = func(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
username, password, ok := r.BasicAuth()
if !ok || 0 == len(username) || 0 == len(password) {
w.WriteHeader(http.StatusForbidden)
return
}
cred, ok := credentials[username]
if !ok {
w.WriteHeader(http.StatusForbidden)
return
}
allowed, err := cred.matches(password)
if nil != err {
log.Println(err)
w.WriteHeader(http.StatusForbidden)
return
}
if !allowed {
w.WriteHeader(http.StatusForbidden)
return
}
handler(w, r)
}
}
}
// Choose and set the appropriate, optimized static file serving function. // Choose and set the appropriate, optimized static file serving function.
var handler http.HandlerFunc var handler http.HandlerFunc
if 0 == len(urlPrefix) { if 0 == len(envvar.urlPrefix) {
handler = handleListing(showListing, basicHandler(folder)) handler = handleListing(envvar.showListing, basicHandler(envvar.folder))
} else { } else {
handler = handleListing(showListing, prefixHandler(folder, urlPrefix)) handler = handleListing(envvar.showListing, prefixHandler(envvar.folder, envvar.urlPrefix))
} }
http.HandleFunc("/", handler) http.HandleFunc("/", auth(handler))
// Serve files over HTTP or HTTPS based on paths to TLS files being provided. // Serve files over HTTP or HTTPS based on paths to TLS files being provided.
if 0 == len(tlsCert) { if 0 == len(envvar.tlsCert) {
log.Fatalln(http.ListenAndServe(host+":"+port, nil)) log.Fatalln(http.ListenAndServe(envvar.host+":"+envvar.port, nil))
} else { } else {
log.Fatalln(http.ListenAndServeTLS(host+":"+port, tlsCert, tlsKey, nil)) log.Fatalln(http.ListenAndServeTLS(envvar.host+":"+envvar.port, envvar.tlsCert, envvar.tlsKey, nil))
} }
} }
@ -176,7 +591,7 @@ func main() {
// will attempt to retrieve the index file of that directory. // will attempt to retrieve the index file of that directory.
func handleListing(show bool, serve http.HandlerFunc) http.HandlerFunc { func handleListing(show bool, serve http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if show || strings.HasSuffix(r.URL.Path, "/") { if !show && strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@ -203,6 +618,58 @@ func prefixHandler(folder, urlPrefix string) http.HandlerFunc {
} }
} }
// loadCredentials from an existing credentials file.
func loadCredentials(filename string) (err error) {
credentials = make(map[string]*credential)
if 0 == len(filename) {
return errors.New("credential file name not set but is required")
}
var contents []byte
if contents, err = ioutil.ReadFile(filename); nil != err {
return
}
return json.Unmarshal(contents, &credentials)
}
// saveCredentials to the specified credentials file.
func saveCredentials(filename string) (err error) {
if 0 == len(filename) {
return errors.New("credential file name not set but is required")
}
var contents []byte
if contents, err = json.Marshal(&credentials); nil != err {
return
}
return ioutil.WriteFile(filename, contents, 0600)
}
// getPassword from the user via the terminal. Mask all characters.
func getPassword() (password string, err error) {
maskInput := true
var rawPassword []byte
var rawConfirmPassword []byte
if rawPassword, err = gopass.GetPasswdPrompt(
"New password:", maskInput, os.Stdin, os.Stdout,
); nil != err {
return
}
if 0 == len(rawPassword) {
err = errors.New("password may not be empty")
return
}
if rawConfirmPassword, err = gopass.GetPasswdPrompt(
"Confirm password:", maskInput, os.Stdin, os.Stdout,
); nil != err {
return
}
if !bytes.Equal(rawPassword, rawConfirmPassword) {
err = errors.New("passwords do not match")
return
}
password = string(rawPassword)
return
}
// env returns the value for an environment variable or, if not set, a fallback // env returns the value for an environment variable or, if not set, a fallback
// value. // value.
func env(key, fallback string) string { func env(key, fallback string) string {