Compare commits

...

33 Commits
v0.4.0 ... main

Author SHA1 Message Date
hedy 8ae39ed6c6
Merge branch 'main' of github.com:hedyhli/spsrv 2023-12-06 09:41:35 +08:00
hedy 593d4ca9cb
Don't show data error if destination URL not found 2023-12-06 09:40:28 +08:00
~hedy d07ffafa2e
Create SECURITY.md for github
lol
2023-08-27 20:30:58 +08:00
hedy 95ac946384
Update README 2023-07-02 14:03:11 +08:00
hedy e504383e4a
Update README to reflect new install instructions 2023-06-19 12:30:21 +08:00
hedy 381097e8e5
Update README for new --version flag 2023-06-19 12:16:37 +08:00
hedy b48fd7633e
Add --version and Makefile etc 2023-06-19 12:15:49 +08:00
hedy b399ace963
Fix 'go install' option for install in READMEs 2023-06-19 10:18:19 +08:00
hedy 2c4c0d81f4
Fix README 2023-06-19 10:10:08 +08:00
hedy 141a7a2bd3
oops 2023-06-18 16:31:47 +08:00
hedy cd14087878
Update README 2023-06-18 16:15:52 +08:00
hedy d6b98d7c84
README.gmi: Remove random quotes 2023-04-02 09:25:59 +08:00
hedy 8995bd4fac
Fix index.gmi CGI for user subdomain request
Excuse, more temp fixes
2022-05-08 18:34:22 +08:00
hedy 60977f6963
README: Update TODO and known servers 2022-05-08 18:26:27 +08:00
hedy 4e2ec455eb
Fix execution of /~user/index.gmi CGI
Fixes #2 (gh).

More like temp fix, but seems to work
2022-05-08 18:20:45 +08:00
hedy 5a16845882
readme: Add example servers 2022-04-01 09:55:55 +08:00
hedy f12ce378ad
Add gemtext readme
Accessible at:

=> gemini://hedy.tilde.cafe/spsrv/
=> spartan://hedy.tilde.cafe:3333/spsrv/
2022-04-01 09:53:17 +08:00
hedy ba2692b5e9
Add example config and CGI
Also wrap lines in readme
2022-04-01 09:45:56 +08:00
hedy f7f6a27898
Fix user CGIs for user vhost requests 2022-03-30 15:38:14 +08:00
hedy 60374d9e70
Add error logging when CGI failed 2022-03-30 15:37:38 +08:00
hedy 1cf4ef3952
README: Fix typos 2022-03-12 16:06:57 +08:00
hedy 2dfaed4fa3
Feat: Per-user vhosts (user.sometilde.org)
Limitations:
- Doesn't care about `extra.stuff.here.user.host.name`
- No custom hostname for user subdomains
- Returns not-found for `user.host.name` where `/home/user/userdir/`
  doesn't exist
2022-03-12 15:51:11 +08:00
hedy efbf17cfd6
DirList: Better log message
Previously: "Serving content: /dir/index.gmi.gmi"
Now       : "Serving content: /dir/"
2022-02-15 10:48:42 +08:00
Hedy Li 66b01724c0
add table of contents to readme 2021-08-19 14:27:51 +08:00
Hedy Li 930dc6380f
Handler permission denied CGI error and serv as static 2021-08-13 12:09:26 +08:00
Hedy Li 01e0687594
add CGI docs to readme, make readme gmi compatible and add $DATA_LENGTH to cgi vars 2021-08-03 10:43:41 +08:00
Hedy Li 44b8fc116d
improve config overrides, cli help, and readme 2021-08-03 10:04:18 +08:00
Hedy Li 8cf1358f1c
fmt 2021-08-03 09:20:47 +08:00
Hedy Li 253fba2824
improve config and readme 2021-08-03 09:20:37 +08:00
Hedy Li c68d835ca0
oops 2021-08-03 08:33:37 +08:00
Hedy Li 4e96db6122
hmm 2021-08-02 21:17:13 +08:00
Hedy Li 7eff471da0
doesnt work yet! 2021-08-02 20:28:52 +08:00
Hedy Li 070098ad2d
update readme 2021-08-02 17:40:36 +08:00
19 changed files with 777 additions and 84 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.out
bin

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 Hedy Li
Copyright (c) 2021-2023 hedy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -18,4 +18,4 @@ 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.
SOFTWARE.

59
Makefile Normal file
View File

@ -0,0 +1,59 @@
.PHONY: help build clean build-all package release
.DEFAULT_GOAL := help
pkg_root = .
### Calculate a few variables for use in building
VERSION = $(shell git describe --tags --abbrev=0 --always)
COMMIT = $(shell git log --pretty='format:%h' -n 1)
BUILDDATE = $(shell date +"%Y-%m-%dT%H:%M:%S")
# ldflags inject new values into variables at compilation time
# this is how we dynamically set the version/etc of the application
ldflags = "-X 'main.appVersion=$(VERSION)' \
-X 'main.appCommit=$(COMMIT)' \
-X 'main.buildTime=$(BUILDDATE)' \
-w -s"
##@ Help
help: ## Display this help
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
@echo
@echo "Variable pkg_root is set to . by default. This should be the directory of spsrv source code."
##@ Utilities
init: ## Install utils
go mod download
dep-tidy: ## Remove unused dependencies
go mod tidy
dep-upgrade: ## Upgrade versions of dependencies
go get -u
##@ Build
build: clean ## Build spsrv for your native architecture
go build -o ./bin/spsrv -ldflags=$(ldflags) $(pkg_root)
build-all: clean ## Build spsrv for linux and mac
GOOS=darwin GOARCH=amd64 go build -o ./bin/spsrv-darwin-amd64/spsrv -ldflags=$(ldflags) $(pkg_root)
GOOS=darwin GOARCH=arm64 go build -o ./bin/spsrv-darwin-arm64/spsrv -ldflags=$(ldflags) $(pkg_root)
GOOS=linux GOARCH=amd64 go build -o ./bin/spsrv-linux-amd64/spsrv -ldflags=$(ldflags) $(pkg_root)
GOOS=linux GOARCH=arm64 go build -o ./bin/spsrv-linux-arm64/spsrv -ldflags=$(ldflags) $(pkg_root)
clean: ## Delete any compiled artifacts
rm -rf ./bin
##@ Release
package: build-all ## Build everything and package up arch-specific tarballs
tar czvf ./bin/spsrv-darwin-amd64.tar.gz ./bin/spsrv-darwin-amd64
tar czvf ./bin/spsrv-darwin-arm64.tar.gz ./bin/spsrv-darwin-arm64
tar czvf ./bin/spsrv-linux-amd64.tar.gz ./bin/spsrv-linux-amd64
tar czvf ./bin/spsrv-linux-arm64.tar.gz ./bin/spsrv-linux-arm64
release: package ## Attach packages to sr.ht ref for current tag
./_scripts/release.sh
##@ Test
test: ## Run tests
go test $(shell go list ./...) -coverprofile=coverage.out
# go tool cover -func=coverage.out

212
README.gmi Normal file
View File

@ -0,0 +1,212 @@
# spsrv
A static spartan server with many features:
* folder redirects
* /~user directories
* directory listing
* CONF or TOML config file
* CGI
Known servers running spsrv
=> spartan://hedy.tilde.cafe:3333
=> spartan://tilde.team
=> spartan://tilde.cafe
=> spartan://earthlight.xyz:3000
=> spartan://jdcard.com:3300
Questions / Support
=> irc://irc.tilde.chat:6697/#spartan #spartan on Tilde.Chat (please ping hedy)
=> mailto:~hedy/inbox@lists.sr.ht Public inbox on lists.sr.ht
Table of Contents
=> #install install
=> #configuration configuation
=> #cli CLI
=> #cgi CGI
=> #todo todo
## install
you have three options:
### Option 1: Prebuilt binary
prebuilt binaries for darwin and linux architectures arm/amd-64 are provided since v0.5.4. Head over to the tags page on git.sr.ht, click on a desired tag and download the binary for your architecture.
=> https://git.sr.ht/~hedy/spsrv/refs
### Option 2: with go
first, you need to have go installed and have a folder ~/go with $GOPATH pointing to it.
```
go install git.sr.ht/~hedy/spsrv@latest
```
there will be a binary at ~/go/bin/ with the source code at ~/go/src/
feel free to move the binary somewhere else like /usr/sbin/
note that it's recommended to pin any latest version `@v0.0.0` rather than the latest commit since it may not be stable.
### Option 3: just build it yourself
run git clone https://git.sr.ht/~hedy/spsrv from any directory and cd spsrv
make sure you have go installed and working.
```
git checkout v0.0.0 # recommended to pin a specific tag
make build
```
when it finishes, the binary will be in ./bin.
if you don't have make, you can just `go build` (just that version and build information will not be available with `spsrv --version`).
### otherwise...
if you do not wish to install go or clone the repo, and your architecture is not supported in the prebuilt binaries, drop an email to my public inbox (or contact me privately) so I could perhaps compile a binary for your architecture.
=> mailto:~hedy/inbox@lists.sr.ht public inbox
## configuration
The default config file location is /etc/spsrv.conf you can specify your own path by running spsrv like
```
spsrv -c /path/to/file.conf
```
You don't need a config file to have spsrv running, it will just use the default values.
config options:
Note that the options are case insensitive.
Here are the config options and their default values
### general
port=300: port to listen to
hostname="localhost": if this is set, any request that for hostnames other than this value would be rejected
rootdir="/var/spartan": folder for fetching files
### directory listing
dirlistEnable=true: enable directory listing for folders that does not have index.gmi
dirlistReverse=false: reverse the order of which files are listed
dirlistSort="name": how files are sorted, only "name", "size", and "time" are accepted. Defaults to "name" if an unknown option is encountered
dirlistTitles=true: if true, directory listing will use first top level header in *.gmi files instead of the filename
### ~user/ directories
userdirEnable=true: enable serving /~user/* requests
userdir="public_spartan": root directory for users. This should not have trailing slashes, and it is relative to /home/user/
userSubdomains=false: User vhosts. Whether to allow user.host.name/foo.txt being the same as host.name/~user/foo.txt (When hostname="host.name"). NOTE: This only works when hostname option is set.
### CGI
CGIPaths=["cgi/"]: list of paths where world-executable files will be run as CGI processes. These paths would be checked if it prefix the requested path. For the default value, a request of /cgi/hi.sh (requesting to ./public/cgi/hi.sh, for example) will run hi.sh script if it's world executable.
usercgiEnable=false: enable running user's CGI scripts too. This is dangerous as spsrv does not (yet) change the Uid of the CGI process, hence the process would be ran by the same user that is running the server, which could mean write access to configuration files, etc. Note that this option will be assumed false if userdirEnable is set to false. Which means if user directories are not enabled, there will be no per-user CGI.
Check out some example configuraton in the examples/ directory.
=> https://tildegit.org/hedy/spsrv/src/branch/main/examples/ examples/
## CLI
You can override values in config file if you supply them from the command line:
```
Usage: spsrv [ [ -c <path> -h <hostname> -p <port> -d <path> ] | --help | --version ]
-c, --config string Path to config file
-d, --dir string Root content directory
-h, --hostname string Hostname
-p, --port int Port to listen to
```
Note that you cannot set the hostname or the dir path to , because spsrv uses that to check whether you provided an option. You can't set port to 0 either, sorry, this limitation comes with the advantage of being able to override config values from the command line.
There are no arguments wanted when running spsrv, only options as listed above :)
## CGI
The following environment values are set for CGI scripts:
```
GATEWAY_INTERFACE # CGI/1.1
REMOTE_ADDR # Remote address
SCRIPT_PATH # (Relative) path of the CGI script
SERVER_SOFTWARE # SPSRV
SERVER_PROTOCOL # SPARTAN
REQUEST_METHOD # Set to nothing
SERVER_PORT # Port
SERVER_NAME # Hostname
DATA_LENGTH # Input data length
```
The data block, if any, will be piped as stdin to the CGI process.
Keep in mind that CGI scripts (as of now) are run by the same user as the server process, hence it is generally dangerous for allowing users to have their own CGI scripts. See configuration section for more details.
Check out some example CGI scripts in the examples/ directory.
=> https://tildegit.org/hedy/spsrv/src/branch/main/examples/ examples/
Example systemd service configurations are also listed there. Feel free to contribute for other OSes :)
## Help / Issues / Feedback
Please either use the #spartan channel on tilde.chat IRC or my public inbox.
Both are listed at the top of this document.
## todo
```todo list
* [x] /folder to /folder/ redirects
* [x] directory listing
* [ ] logging to files
* [x] ~user directories
* [x] refactor working dir part
* [x] config
* [ ] status meta
* [x] user homedir
* [x] hostname, port
* [x] public dir
* [x] dirlist title
* [x] user vhost
* [ ] userdir slug
* [ ] redirects
* [x] CGI
* [x] pipe data block
* [ ] user cgi config and change uid to user
* [ ] regex in cgi paths
* [ ] SCGI
* [ ] Multiple servers with each of their own confs
README:
* [x] Add example confs (added in examples/ directory)
* [x] Add example .service files (added in examples/ directory)
```

208
README.md
View File

@ -6,11 +6,202 @@ A static spartan server with many features:
* /~user directories
* directory listing
* CONF or TOML config file
* directory listing options
* user directory feature and userdir path
* CGI
Known servers running spsrv:
* [hedy.tilde.cafe:3333](https://portal.mozz.us/spartan/hedy.tilde.cafe:3333)
* [tilde.team](https://portal.mozz.us/spartan/tilde.team)
* [tilde.cafe](https://portal.mozz.us/spartan/tilde.cafe)
* [earthlight.xyz:3000](https://portal.mozz.us/spartan/earthlight.xyz:3000)
* [jdcard.com:3300](https://portal.mozz.us/spartan/jdcard.com:3300/)
**Questions / Support**
* [#spartan on Tilde.Chat IRC](https://tilde.chat/kiwi/#spartan) (please ping
hedy)
* [Public inbox](mailto:~hedy/inbox@lists.sr.ht) (general mailing list on
lists.sr.ht)
* Patches: { [~hedy/inbox at lists.sr.ht](https://lists.sr.ht/~hedy/inbox) }
---
**Table of contents**
<!-- vim-markdown-toc GFM -->
* [install](#install)
* [Option 1: prebuilt binaries](#option-1-prebuilt-binaries)
* [Option 2: with `go install`](#option-2-with-go-install)
* [Option 3: just build it yourself](#option-3-just-build-it-yourself)
* [otherwise...](#otherwise)
* [configuration](#configuration)
* [config options](#config-options)
* [CLI](#cli)
* [CGI](#cgi)
* [Help / Issues / Feedback](#help--issues--feedback)
* [todo](#todo)
<!-- vim-markdown-toc -->
## install
you have three options:
### Option 1: prebuilt binaries
prebuilt binaries for darwin and linux architectures arm/amd-64 are provided
since v0.5.4. Head over to the [tags page on
git.sr.ht](https://git.sr.ht/~hedy/spsrv/refs), click on a desired tag and
download the binary for your architecture.
### Option 2: with `go install`
first, you need to have go installed and have a folder `~/go` with `$GOPATH`
pointing to it.
```
go install git.sr.ht/~hedy/spsrv@latest
```
there will be a binary at `~/go/bin/` with the source code at `~/go/src/`
feel free to move the binary somewhere else like `/usr/sbin/`
note that it's recommended to pin any latest version `@v0.0.0` rather than the
latest commit since it may not be stable.
### Option 3: just build it yourself
run `git clone https://git.sr.ht/~hedy/spsrv` from any directory and `cd spsrv`
make sure you have go installed and working.
```
git checkout v0.0.0 # recommended to pin a specific tag
make build
```
when it finishes, the binary will be in `./bin`.
if you don't have make, you can just `go build` (just that version and build
information will not be available with `spsrv --version`).
### otherwise...
if you do not wish to install go or clone the repo, and your architecture is not
supported in the prebuilt binaries, drop an email to my [public
inbox](mailto:~hedy/inbox@lists.sr.ht) (or contact me privately) so I could
perhaps compile a binary for your architecture.
## configuration
The default config file location is `/etc/spsrv.conf` you can specify your own
path by running spsrv like
```
spsrv -c /path/to/file.conf
```
You don't need a config file to have spsrv running, it will just use the
default values.
### config options
Note that the options are case insensitive.
Here are the config options and their default values
**general**
* `port=300`: port to listen to
* `hostname="localhost"`: if this is set, any request that for hostnames other than this value would be rejected
* `rootdir="/var/spartan"`: folder for fetching files
**directory listing**
* `dirlistEnable=true`: enable directory listing for folders that does not have `index.gmi`
* `dirlistReverse=false`: reverse the order of which files are listed
* `dirlistSort="name"`: how files are sorted, only "name", "size", and "time" are accepted. Defaults to "name" if an unknown option is encountered
* `dirlistTitles=true`: if true, directory listing will use first top level header in `*.gmi` files instead of the filename
**~user/ directories**
* `userdirEnable=true`: enable serving `/~user/*` requests
* `userdir="public_spartan"`: root directory for users. This should not have trailing slashes, and it is relative to `/home/user/`
* `userSubdomains=false`: User vhosts. Whether to allow `user.host.name/foo.txt` being the same as `host.name/~user/foo.txt` (When `hostname="host.name"`). **NOTE**: This only works when `hostname` option is set.
**CGI**
* `CGIPaths=["cgi/"]`: list of paths where world-executable files will be run as CGI processes. These paths would be checked if it prefix the requested path. For the default value, a request of `/cgi/hi.sh` (requesting to `./public/cgi/hi.sh`, for example) will run `hi.sh` script if it's world executable.
* `usercgiEnable=false`: enable running user's CGI scripts too. This is dangerous as spsrv does not (yet) change the Uid of the CGI process, hence the process would be ran by the same user that is running the server, which could mean write access to configuration files, etc. Note that this option will be assumed `false` if `userdirEnable` is set to `false`. Which means if user directories are not enabled, there will be no per-user CGI.
Check out some example configuraton in the [examples/](examples/) directory.
## CLI
You can override values in config file if you supply them from the command line:
```
Usage: spsrv [ [ -c <path> -h <hostname> -p <port> -d <path> ] | --help | --version ]
-c, --config string Path to config file
-d, --dir string Root content directory
-h, --hostname string Hostname
-p, --port int Port to listen to
```
Note that you *cannot* set the hostname or the dir path to `,` because spsrv
uses that to check whether you provided an option. You can't set port to `0`
either, sorry, this limitation comes with the advantage of being able to
override config values from the command line.
There are no arguments wanted when running spsrv, only options as listed above :)
## CGI
The following environment values are set for CGI scripts:
```
GATEWAY_INTERFACE # CGI/1.1
REMOTE_ADDR # Remote address
SCRIPT_PATH # (Relative) path of the CGI script
SERVER_SOFTWARE # SPSRV
SERVER_PROTOCOL # SPARTAN
REQUEST_METHOD # Set to nothing
SERVER_PORT # Port
SERVER_NAME # Hostname
DATA_LENGTH # Input data length
```
The data block, if any, will be piped as stdin to the CGI process.
Keep in mind that CGI scripts (as of now) are run by the same user as the
server process, hence it is generally dangerous for allowing users to have
their own CGI scripts. See configuration section for more details.
Check out some example CGI scripts in the [examples/](examples/) directory.
Example systemd service configurations are also listed there. Feel free to
contribute for other OSes :)
## Help / Issues / Feedback
Please either use the [#spartan channel on tilde.chat
IRC](https://tilde.chat/kiwi/#spartan) or my [public
inbox](https://lists.sr.ht/~hedy/inbox).
Both are listed at the top of this document.
**Patches** -> [public inbox](https://lists.sr.ht/~hedy/inbox)
## todo
- [x] /folder to /folder/ redirects
- [x] directory listing
- [ ] logging to files
@ -21,9 +212,18 @@ A static spartan server with many features:
- [x] user homedir
- [x] hostname, port
- [x] public dir
- [ ] dirlist title
- [x] dirlist title
- [x] user vhost
- [ ] userdir slug
- [ ] redirects
- [x] CGI
- [ ] pipe data block
- [x] pipe data block
- [ ] user cgi config and change uid to user
- [ ] regex in cgi paths
- [ ] SCGI
- [ ] Multiple servers with each of their own confs
README:
- [x] Add example confs (added in [examples/](examples) directory)
- [x] Add example .service files (added in [examples/](examples) directory)

23
SECURITY.md Normal file
View File

@ -0,0 +1,23 @@
# Security Policy
## Supported Versions
Version of the server used should be included when reporting vulnerabilities,
however please try to use the latest versions if you are the server admin.
| Version | Supported |
| ------- | ------------------ |
| 0.5.x | :white_check_mark: |
## Reporting a Vulnerability
Send an email to **hedy at tilde dot cafe**.
Do NOT use my public inbox on lists.sr.ht or tell me publicly since other public
servers running on spsrv (not only yours) could be prone to the same issue.
=> [mailto link](mailto:hedy@tilde.cafe)
You can expect emails to be read within a week, and I will reply promply to indicate when
I'll be able to work on a fix, if possible.

20
_scripts/release.sh Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env sh
set -e
git_tag=`git describe --exact-match 2> /dev/null || echo ""`
if [ "$git_tag" != "" ]; then
echo "Releasing $git_tag!"
mv ./bin/spsrv-darwin-amd64.tar.gz ./bin/spsrv-darwin-amd64-$git_tag.tar.gz
mv ./bin/spsrv-darwin-arm64.tar.gz ./bin/spsrv-darwin-arm64-$git_tag.tar.gz
mv ./bin/spsrv-linux-amd64.tar.gz ./bin/spsrv-linux-amd64-$git_tag.tar.gz
mv ./bin/spsrv-linux-arm64.tar.gz ./bin/spsrv-linux-arm64-$git_tag.tar.gz
curl -H"Authorization: token $SRHT_TOKEN" https://git.sr.ht/api/~hedy/repos/spsrv/artifacts/$git_tag -F "file=@./bin/spsrv-darwin-amd64-$git_tag.tar.gz"
curl -H"Authorization: token $SRHT_TOKEN" https://git.sr.ht/api/~hedy/repos/spsrv/artifacts/$git_tag -F "file=@./bin/spsrv-darwin-arm64-$git_tag.tar.gz"
curl -H"Authorization: token $SRHT_TOKEN" https://git.sr.ht/api/~hedy/repos/spsrv/artifacts/$git_tag -F "file=@./bin/spsrv-linux-amd64-$git_tag.tar.gz"
curl -H"Authorization: token $SRHT_TOKEN" https://git.sr.ht/api/~hedy/repos/spsrv/artifacts/$git_tag -F "file=@./bin/spsrv-linux-arm64-$git_tag.tar.gz"
echo ""
echo "DONE!"
else
echo "Non-tagged commit, not releasing!"
fi

View File

@ -10,31 +10,33 @@ import (
)
type Config struct {
Port int
Hostname string
RootDir string
UserDirEnable bool
UserDir string
DirlistEnable bool
DirlistReverse bool
DirlistSort string
DirlistTitles bool
RestrictHostname string
CGIPaths []string
Port int
Hostname string
RootDir string
UserDirEnable bool
UserDir string
UserSubdomains bool
DirlistEnable bool
DirlistReverse bool
DirlistSort string
DirlistTitles bool
CGIPaths []string
UserCGIEnable bool
}
var defaultConf = &Config{
Port: 300,
Hostname: "localhost",
RootDir: "/var/spartan/",
DirlistEnable: true,
DirlistReverse: false,
DirlistSort: "name",
DirlistTitles: true,
UserDirEnable: true,
UserDir: "public_spartan",
RestrictHostname: "",
CGIPaths: []string{"cgi/"},
Port: 300,
Hostname: "localhost",
RootDir: "/var/spartan/",
DirlistEnable: true,
DirlistReverse: false,
DirlistSort: "name",
DirlistTitles: true,
UserDirEnable: true,
UserDir: "public_spartan",
UserSubdomains: false,
CGIPaths: []string{"cgi/"},
UserCGIEnable: false, // Turned off by default because scripts are run by server user as of now
}
func LoadConfig(path string) (*Config, error) {

View File

@ -26,10 +26,12 @@ func handleCGI(conf *Config, req *Request, cgiPath string) (ok bool) {
info, err := os.Stat(scriptPath)
if err != nil {
log.Println(err.Error())
ok = false
return
}
if !(info.Mode().Perm()&0555 == 0555) {
log.Println("File not executable")
ok = false
return
}
@ -76,15 +78,19 @@ func handleCGI(conf *Config, req *Request, cgiPath string) (ok bool) {
if ctx.Err() == context.DeadlineExceeded {
log.Println("Terminating CGI process " + path + " due to exceeding 10 second runtime limit.")
conn.Write([]byte("42 CGI process timed out!\r\n"))
conn.Write([]byte("5 CGI process timed out!\r\n"))
return
}
if err != nil {
log.Println("Error running CGI program " + path + ": " + err.Error())
if strings.Contains(err.Error(), "permission denied") {
ok = false
return
}
if err, ok := err.(*exec.ExitError); ok {
log.Println("↳ stderr output: " + string(err.Stderr))
}
conn.Write([]byte("42 CGI error\r\n"))
conn.Write([]byte("5 CGI error\r\n"))
return
}
// Extract response header
@ -92,7 +98,7 @@ func handleCGI(conf *Config, req *Request, cgiPath string) (ok bool) {
_, err2 := strconv.Atoi(strings.Fields(string(header))[0])
if err != nil || err2 != nil {
log.Println("Unable to parse first line of output from CGI process " + path + " as valid Gemini response header. Line was: " + string(header))
conn.Write([]byte("42 CGI error\r\n"))
conn.Write([]byte("5 CGI error\r\n"))
return
}
log.Println("Returning CGI output")
@ -110,13 +116,14 @@ func prepareCGIVariables(conf *Config, req *Request, script_path string) map[str
func prepareGatewayVariables(conf *Config, req *Request) map[string]string {
vars := make(map[string]string)
// vars["QUERY_STRING"] = URL.RawQuery
vars["REQUEST_METHOD"] = ""
vars["SERVER_NAME"] = conf.Hostname
vars["SERVER_PORT"] = strconv.Itoa(conf.Port)
vars["SERVER_PROTOCOL"] = "SPARTAN"
vars["SERVER_SOFTWARE"] = "SPSRV"
vars["DATA_LENGTH"] = strconv.Itoa(req.dataLen)
host, _, _ := net.SplitHostPort((*req.netConn).RemoteAddr().String())
vars["REMOTE_ADDR"] = host
return vars

6
examples/cgi/echo Executable file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env sh
# Echos back whatever data you send it
printf "2 application/octet-stream\r\n"
cat /dev/stdin

5
examples/cgi/env.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env sh
printf "2 text/gemini\r\n"
whoami
printenv

10
examples/cgi/greet.sh Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env sh
# User can enter their name and this will greet them
# Example inpu link:
#
# =: greet.sh Enter your name
printf "2 text/plain\r\n"
name=$(cat /dev/stdin)
echo "Hello, ${name:-World}!"

View File

@ -0,0 +1,13 @@
# Example config for a local testing server
hostname="localhost"
port=3000
rootDir = "./public"
# attempt CGI for all requests
cgiPaths=[""]
dirlistEnable=true
dirlistSort="time"
dirlistTitles=true

View File

@ -0,0 +1,19 @@
# Example config for a multi-user unix server
# accept any hostname
hostname=""
port=300
rootdir="/var/spartan"
# allow CGI for all files - so you can have your root index.gmi do a user
# listing
cgipaths=[""]
userdirEnable=true
# each user would have their content be at /home/user/public_spartan,
# accessible via spartan://host.name/~user/
userdir="public_spartan"
# enable per-user CGI (you might wanna be running the spartan server under
# another user, such as spartan:nogroup)
usercgiEnable=true

View File

@ -0,0 +1,18 @@
# Example config for allowing per-user vhosts on your pubnix, i.e.: allownig
# spartan://user.example.org
# you must set a non-empty hostname for user-vhost to work
hostname="example.org"
port=300
userdirEnable=true
# each user would have their content be at /home/user/public_spartan,
# accessible via both spartan://example.org/~user/ and
# spartan://user.example.org/
userdir="public_spartan"
userSubdomains=true
# enable per-user CGI (you might wanna be running the spartan server under
# another user, such as spartan:nogroup)
usercgiEnable=true

View File

@ -0,0 +1,15 @@
[Unit]
Description=spsrv
After=network.target
[Service]
Type=simple
Restart=always
RestartSec=5
User=spartan
Group=spartan
ExecStart=/usr/local/bin/spsrv -c /etc/spsrv.conf
[Install]
WantedBy=multi-user.target

2
go.mod
View File

@ -3,6 +3,6 @@ module spsrv
go 1.15
require (
github.com/BurntSushi/toml v0.3.1
github.com/BurntSushi/toml v1.3.2
github.com/spf13/pflag v1.0.5
)

4
go.sum
View File

@ -1,4 +1,4 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=

180
spsrv.go
View File

@ -18,14 +18,14 @@ import (
flag "github.com/spf13/pflag"
)
var doneScanningRequest = false
type Request struct {
conn io.ReadWriteCloser
netConn *net.Conn
vhost string
user string
path string // Requested path
filePath string // Actual file path that does not include the content dir name
dataLen int
data string
}
@ -36,15 +36,56 @@ const (
statusServerError = 5
)
// The following default values are set so that a user would never set any value from the CLI to
// the following. so we can distinguish between user supplied value and the default value.
// The default char is not "" because you can set hostname to "" and it will allow requests to
// any hostname.
// This is not using defaultConf values either because if the config has non-default values, and
// default value is supplied from the CLI, we want to keep taht default value, which is likely what
// user wants.
var cliDefaultChar = ","
var cliDefaultInt = 0
var (
hostname = flag.StringP("hostname", "h", defaultConf.Hostname, "Hostname")
port = flag.IntP("port", "p", defaultConf.Port, "Port to listen to")
rootDir = flag.StringP("dir", "d", defaultConf.RootDir, "Root content directory")
hostname = flag.StringP("hostname", "h", cliDefaultChar, "Hostname")
port = flag.IntP("port", "p", cliDefaultInt, "Port to listen to")
rootDir = flag.StringP("dir", "d", cliDefaultChar, "Root content directory")
confPath = flag.StringP("config", "c", "/etc/spsrv.conf", "Path to config file")
helpFlag = flag.BoolP("help", "?", false, "Get CLI help")
versionFlag = flag.BoolP("version", "v", false, "View version and exit")
)
var (
appVersion = "unknown version"
buildTime = "date unknown"
appCommit = "unknown"
)
func main() {
// Custom usage function because we don't want the "pflag: help requested" message, and
// we don't want to show the default values.
flag.Usage = func() {
fmt.Println(`Usage: spsrv [ [ -c <path> -h <hostname> -p <port> -d <path> ] | --help | --version ]
-c, --config string Path to config file
-d, --dir string Root content directory
-h, --hostname string Hostname
-p, --port int Port to listen to`)
}
flag.Parse()
if *helpFlag {
flag.Usage()
return
}
if *versionFlag {
fmt.Printf("spsrv %s, commit %s, built %s", appVersion, appCommit, buildTime)
return
}
conf, err := LoadConfig(*confPath)
if err != nil {
fmt.Println("Error loading config")
@ -53,17 +94,16 @@ func main() {
}
// This allows users overriding values in config via the CLI
if *hostname != defaultConf.Hostname {
if *hostname != cliDefaultChar {
conf.Hostname = *hostname
}
if *port != defaultConf.Port {
if *port != cliDefaultInt {
conf.Port = *port
}
if *rootDir != defaultConf.RootDir {
if *rootDir != cliDefaultChar {
conf.RootDir = *rootDir
}
// TODO: do something with conf.Hostname (b(like restricting to ipv4/6 etc)
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", conf.Port))
if err != nil {
log.Fatalf("Unable to listen: %s", err)
@ -96,9 +136,24 @@ func handleConnection(netConn net.Conn, conf *Config) {
log.Println("Closed connection")
}()
doneScanningRequest := false
// Check the size of the request buffer.
s := bufio.NewScanner(conn)
s.Split(ScanRequest)
s.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if doneScanningRequest {
// Return a byte
return 1, data[:1], nil
}
// Read request
if i := bytes.IndexByte(data, '\n'); i >= 0 {
return i + 1, bytes.TrimRight(data[0:i], "\r"), nil
}
return 0, nil, nil
})
// Sanity check incoming request URL content.
if ok := s.Scan(); !ok {
@ -116,11 +171,17 @@ func handleConnection(netConn net.Conn, conf *Config) {
sendResponseHeader(conn, statusClientError, "Bad request")
return
}
if conf.RestrictHostname != "" {
if conf.RestrictHostname != host {
log.Println("Request host does not match conf.RestrictHostname, returning client error.")
sendResponseHeader(conn, statusClientError, "No proxying to other hosts!")
return
userSubdomainReq := false
if conf.Hostname != "" {
if conf.Hostname != host {
if conf.UserDirEnable && conf.UserSubdomains && strings.HasSuffix(host, conf.Hostname) {
userSubdomainReq = true
}
if !userSubdomainReq {
log.Println("Request host does not match config value Hostname, returning client error.")
sendResponseHeader(conn, statusClientError, "No proxying to other hosts!")
return
}
}
}
if strings.Contains(reqPath, "..") {
@ -146,7 +207,13 @@ func handleConnection(netConn net.Conn, conf *Config) {
data += newData
}
}
req := &Request{path: reqPath, netConn: &netConn, conn: conn, data: data}
var vhost string
if userSubdomainReq {
// TODO: Handle extra dots like a.b.host.name?
vhost = strings.TrimSuffix(host, "."+conf.Hostname)
}
req := &Request{vhost: vhost, path: reqPath, netConn: &netConn, conn: conn, data: data, dataLen: dataLen}
// Time to fetch the files!
path := resolvePath(reqPath, conf, req)
@ -154,49 +221,67 @@ func handleConnection(netConn net.Conn, conf *Config) {
// Check for CGI
for _, cgiPath := range conf.CGIPaths {
if strings.HasPrefix(req.filePath, cgiPath) {
if req.user != "" && (!conf.UserCGIEnable || !conf.UserDirEnable) {
break
}
if req.user != "" && (req.filePath == "" || req.filePath == "/") {
// TODO: Refactor - ATM `path` would contain the current CGI file wanted
// But for hitting /~user/, req.filePath is NOT index.gmi
req.filePath = "index.gmi"
}
log.Println("Attempting CGI:", req.filePath)
ok := handleCGI(conf, req, cgiPath)
if ok {
return
}
break // CGI failed. just handle the request as if it's a static file.
}
}
// Reaching here means it is a static file
if dataLen != 0 {
log.Printf("Got data block of length %v, returning client error.", dataLen)
sendResponseHeader(conn, statusClientError, "Unwanted input data block received")
return
log.Printf("Got data block of length %v for request where CGI not found.", dataLen)
// Not erroring out here because if file not found, return not found rather
// than 'Unexpected input'
}
serveFile(conn, reqPath, path, conf)
serveFile(conn, reqPath, path, conf, dataLen != 0)
}
// resolvePath takes in teh request path and returns the cleaned filepath that needs to be fetched.
// It also handles user directories paths /~user/ and /~user if user directories is enabled in the config.
func resolvePath(reqPath string, conf *Config, req *Request) (path string) {
// Handle tildes
if conf.UserDirEnable && strings.HasPrefix(reqPath, "/~") {
var user string
// Handle user subdomains
if req.vhost != "" {
user = req.vhost
path = reqPath
} else if conf.UserDirEnable && strings.HasPrefix(reqPath, "/~") {
// Handle tildes
// Note that user.host.name/~user/ would treat it as a literal folder named /~user/
// (hence using `else if`)
bits := strings.Split(reqPath, "/")
username := bits[1][1:]
user = bits[1][1:]
// /~user to /~user/ is somehow able to be handled together with any other /folder to /foler/ redirects
// /~user to /~user/ is somehow able to be handled together with any other /folder to /folder/ redirects
// So I won't worry about that nor handle it specifically
req.filePath = strings.TrimPrefix(filepath.Clean(strings.TrimPrefix(reqPath, "/~"+username)), "/")
req.filePath = strings.TrimPrefix(filepath.Clean(strings.TrimPrefix(reqPath, "/~"+user)), "/")
path = req.filePath
}
new_prefix := filepath.Join("/home/", username, conf.UserDir)
req.user = username
path = filepath.Clean(strings.Replace(reqPath, bits[1], new_prefix, 1))
if user != "" {
req.filePath = path
path = filepath.Join("/home/", user, conf.UserDir, path)
req.user = user
if strings.HasSuffix(reqPath, "/") {
path = filepath.Join(path, "index.gmi")
}
return
}
path = reqPath
// TODO: [config] default index file for a directory is index.gmi
if strings.HasSuffix(reqPath, "/") || reqPath == "" {
@ -208,7 +293,7 @@ func resolvePath(reqPath string, conf *Config, req *Request) (path string) {
}
// serveFile serves opens the requested path and returns the file content
func serveFile(conn io.ReadWriteCloser, reqPath, path string, conf *Config) {
func serveFile(conn io.ReadWriteCloser, reqPath, path string, conf *Config, hasData bool) {
// If the content directory is not specified as an absolute path, make it absolute.
// prefixDir := ""
// var rootDir http.Dir
@ -227,7 +312,11 @@ func serveFile(conn io.ReadWriteCloser, reqPath, path string, conf *Config) {
// be opened without errors
// Directory listing
if conf.DirlistEnable && strings.HasSuffix(path, "index.gmi") {
// fullPath := filepath.Join(fmt.Sprint(rootDir), path)
if hasData {
log.Println("Returning client error due to unexpected data block")
sendResponseHeader(conn, statusClientError, "Unexpected input data block received")
return
}
fullPath := path
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
// If and only if the path is index.gmi AND index.gmi does not exist
@ -241,18 +330,27 @@ func serveFile(conn io.ReadWriteCloser, reqPath, path string, conf *Config) {
sendResponseHeader(conn, statusServerError, "Error generating directory listing")
return
}
path += ".gmi" // OOF, this is just to have the text/gemini meta later lol
path = strings.TrimSuffix(path, "index.gmi")
serveContent(conn, content, path)
return
}
}
}
log.Println(err)
log.Println("Returning not found")
sendResponseHeader(conn, statusClientError, "Not found")
return
}
defer f.Close()
// Only show this if we are certain that the request was for a static file.
// Which does not include the 'Not found'.
if hasData {
log.Println("Returning client error due to unexpected data block")
sendResponseHeader(conn, statusClientError, "Unexpected input data block received")
return
}
// Read da file
content, err = ioutil.ReadAll(f)
if err != nil {
@ -275,7 +373,7 @@ func serveFile(conn io.ReadWriteCloser, reqPath, path string, conf *Config) {
func serveContent(conn io.ReadWriteCloser, content []byte, path string) {
// MIME
meta := http.DetectContentType(content)
if strings.HasSuffix(path, ".gmi") {
if strings.HasSuffix(path, ".gmi") || strings.HasSuffix(path, "/") {
meta = "text/gemini; lang=en; charset=utf-8" // TODO: configure custom meta string
}
@ -318,19 +416,3 @@ func parseRequest(r string) (host, path string, contentLength int, err error) {
}
return
}
// ScanRequest returns a line if we haven't scanned request line yet, else, returns a byte
func ScanRequest(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if doneScanningRequest {
// Return a byte
return 1, data[:1], nil
}
// Read request
if i := bytes.IndexByte(data, '\n'); i >= 0 {
return i + 1, bytes.TrimRight(data[0:i], "\r"), nil
}
return 0, nil, nil
}