├── .all-contributorsrc ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── release-drafter.yml ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png ├── screenshot4.png └── workflows │ └── draft-release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bin └── my_init ├── cmd └── subspace │ ├── assets.go │ ├── config.go │ ├── handlers.go │ ├── mailer.go │ ├── main.go │ ├── utils.go │ └── web.go ├── entrypoint.sh ├── go.mod ├── go.sum ├── scripts └── dockerfiles │ ├── 386.dockerfile │ ├── amd64.dockerfile │ ├── arm32v5.dockerfile │ ├── arm32v6.dockerfile │ ├── arm32v7.dockerfile │ ├── arm64v8.dockerfile │ └── hooks │ ├── post_push │ └── pre_build └── web ├── email ├── footer.html ├── forgot.html └── header.html ├── static ├── favicon.png ├── fonts │ ├── Roboto-Black.ttf │ ├── Roboto-BlackItalic.ttf │ ├── Roboto-Bold.ttf │ ├── Roboto-BoldItalic.ttf │ ├── Roboto-Italic.ttf │ ├── Roboto-Light.ttf │ ├── Roboto-LightItalic.ttf │ ├── Roboto-Medium.ttf │ ├── Roboto-MediumItalic.ttf │ ├── Roboto-Regular.ttf │ ├── Roboto-Thin.ttf │ └── Roboto-ThinItalic.ttf ├── jquery.min.js ├── roboto.css ├── script.js ├── semantic │ ├── semantic.min.css │ ├── semantic.min.js │ └── themes │ │ └── default │ │ └── assets │ │ ├── fonts │ │ ├── brand-icons.eot │ │ ├── brand-icons.svg │ │ ├── brand-icons.ttf │ │ ├── brand-icons.woff │ │ ├── brand-icons.woff2 │ │ ├── icons.eot │ │ ├── icons.otf │ │ ├── icons.svg │ │ ├── icons.ttf │ │ ├── icons.woff │ │ ├── icons.woff2 │ │ ├── outline-icons.eot │ │ ├── outline-icons.svg │ │ ├── outline-icons.ttf │ │ ├── outline-icons.woff │ │ └── outline-icons.woff2 │ │ └── images │ │ └── flags.png └── style.css └── templates ├── configure.html ├── footer.html ├── forgot.html ├── header.html ├── help.html ├── index.html ├── profile ├── connect.html └── delete.html ├── settings.html ├── signin.html └── user ├── delete.html └── edit.html /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "dmacvicar", 10 | "name": "Duncan Mac-Vicar P.", 11 | "avatar_url": "https://avatars2.githubusercontent.com/u/15332?v=4", 12 | "profile": "https://duncan.codes", 13 | "contributions": [ 14 | "code" 15 | ] 16 | }, 17 | { 18 | "login": "valentin2105", 19 | "name": "Valentin Ouvrard", 20 | "avatar_url": "https://avatars1.githubusercontent.com/u/12403145?v=4", 21 | "profile": "https://opsnotice.xyz", 22 | "contributions": [ 23 | "code" 24 | ] 25 | }, 26 | { 27 | "login": "agonbar", 28 | "name": "Adrián González Barbosa", 29 | "avatar_url": "https://avatars3.githubusercontent.com/u/1553211?v=4", 30 | "profile": "https://github.com/agonbar", 31 | "contributions": [ 32 | "code" 33 | ] 34 | }, 35 | { 36 | "login": "gavinelder", 37 | "name": "Gavin", 38 | "avatar_url": "https://avatars3.githubusercontent.com/u/1226100?v=4", 39 | "profile": "http://www.improbable.io", 40 | "contributions": [ 41 | "code" 42 | ] 43 | }, 44 | { 45 | "login": "squat", 46 | "name": "Lucas Servén Marín", 47 | "avatar_url": "https://avatars1.githubusercontent.com/u/20484159?v=4", 48 | "profile": "https://squat.ai", 49 | "contributions": [ 50 | "code" 51 | ] 52 | }, 53 | { 54 | "login": "jack1902", 55 | "name": "Jack", 56 | "avatar_url": "https://avatars2.githubusercontent.com/u/39212456?v=4", 57 | "profile": "https://github.com/jack1902", 58 | "contributions": [ 59 | "code" 60 | ] 61 | }, 62 | { 63 | "login": "ssiuhk", 64 | "name": "Sam SIU", 65 | "avatar_url": "https://avatars3.githubusercontent.com/u/23556929?v=4", 66 | "profile": "https://github.com/ssiuhk", 67 | "contributions": [ 68 | "code" 69 | ] 70 | }, 71 | { 72 | "login": "wizardels", 73 | "name": "Elliot Westlake", 74 | "avatar_url": "https://avatars0.githubusercontent.com/u/17042376?v=4", 75 | "profile": "https://github.com/wizardels", 76 | "contributions": [ 77 | "code" 78 | ] 79 | }, 80 | { 81 | "login": "clementperon", 82 | "name": "Clément Péron", 83 | "avatar_url": "https://avatars.githubusercontent.com/u/1859302?v=4", 84 | "profile": "https://github.com/clementperon", 85 | "contributions": [ 86 | "doc" 87 | ] 88 | }, 89 | { 90 | "login": "selvakn", 91 | "name": "Selva", 92 | "avatar_url": "https://avatars.githubusercontent.com/u/30524?v=4", 93 | "profile": "http://blog.selvakn.in", 94 | "contributions": [ 95 | "doc" 96 | ] 97 | }, 98 | { 99 | "login": "syphernl", 100 | "name": "Frank", 101 | "avatar_url": "https://avatars.githubusercontent.com/u/639906?v=4", 102 | "profile": "https://github.com/syphernl", 103 | "contributions": [ 104 | "code" 105 | ] 106 | }, 107 | { 108 | "login": "gianlazz", 109 | "name": "Gian Lazzarini", 110 | "avatar_url": "https://avatars.githubusercontent.com/u/1166579?v=4", 111 | "profile": "https://github.com/gianlazz", 112 | "contributions": [ 113 | "doc" 114 | ] 115 | }, 116 | { 117 | "login": "nhamlh", 118 | "name": "Nham Le", 119 | "avatar_url": "https://avatars.githubusercontent.com/u/11173217?v=4", 120 | "profile": "https://nhamlh.space", 121 | "contributions": [ 122 | "code" 123 | ] 124 | }, 125 | { 126 | "login": "sinanmohd", 127 | "name": "Sinan Mohd", 128 | "avatar_url": "https://avatars.githubusercontent.com/u/69694713?v=4", 129 | "profile": "https://github.com/sinanmohd", 130 | "contributions": [ 131 | "doc" 132 | ] 133 | }, 134 | { 135 | "login": "SGudbrandsson", 136 | "name": "Sigurður Guðbrandsson", 137 | "avatar_url": "https://avatars.githubusercontent.com/u/1608474?v=4", 138 | "profile": "http://www.sigginet.info", 139 | "contributions": [ 140 | "code" 141 | ] 142 | }, 143 | { 144 | "login": "vojta7", 145 | "name": "vojta7", 146 | "avatar_url": "https://avatars.githubusercontent.com/u/10436347?v=4", 147 | "profile": "https://github.com/vojta7", 148 | "contributions": [ 149 | "code" 150 | ] 151 | }, 152 | { 153 | "login": "d3473r", 154 | "name": "Fabian", 155 | "avatar_url": "https://avatars.githubusercontent.com/u/10356892?v=4", 156 | "profile": "https://github.com/d3473r", 157 | "contributions": [ 158 | "doc" 159 | ] 160 | }, 161 | { 162 | "login": "miki725", 163 | "name": "Miroslav Shubernetskiy", 164 | "avatar_url": "https://avatars.githubusercontent.com/u/932940?v=4", 165 | "profile": "http://miki725.com", 166 | "contributions": [ 167 | "doc" 168 | ] 169 | }, 170 | { 171 | "login": "dovreshef", 172 | "name": "dovreshef", 173 | "avatar_url": "https://avatars.githubusercontent.com/u/5120549?v=4", 174 | "profile": "https://github.com/dovreshef", 175 | "contributions": [ 176 | "code" 177 | ] 178 | }, 179 | { 180 | "login": "Freekers", 181 | "name": "Freekers", 182 | "avatar_url": "https://avatars.githubusercontent.com/u/1370857?v=4", 183 | "profile": "https://freek.ws/", 184 | "contributions": [ 185 | "code" 186 | ] 187 | }, 188 | { 189 | "login": "Coffeeri", 190 | "name": "Leander", 191 | "avatar_url": "https://avatars.githubusercontent.com/u/8344540?v=4", 192 | "profile": "https://github.com/Coffeeri", 193 | "contributions": [ 194 | "doc" 195 | ] 196 | }, 197 | { 198 | "login": "gchamon", 199 | "name": "Gabriel Chamon Araujo", 200 | "avatar_url": "https://avatars.githubusercontent.com/u/9471861?v=4", 201 | "profile": "https://github.com/gchamon", 202 | "contributions": [ 203 | "code" 204 | ] 205 | }, 206 | { 207 | "login": "audibleblink", 208 | "name": "Alex Flores", 209 | "avatar_url": "https://avatars.githubusercontent.com/u/4605783?v=4", 210 | "profile": "http://alexflor.es", 211 | "contributions": [ 212 | "code" 213 | ] 214 | }, 215 | { 216 | "login": "jpbostic", 217 | "name": "Jared P Bostic", 218 | "avatar_url": "https://avatars.githubusercontent.com/u/5026236?v=4", 219 | "profile": "https://jaredpbostic.com/about/", 220 | "contributions": [ 221 | "code" 222 | ] 223 | }, 224 | { 225 | "login": "ThisIsQasim", 226 | "name": "Qasim Mehmood", 227 | "avatar_url": "https://avatars.githubusercontent.com/u/18313886?v=4", 228 | "profile": "https://github.com/ThisIsQasim", 229 | "contributions": [ 230 | "code" 231 | ] 232 | }, 233 | { 234 | "login": "maduggan", 235 | "name": "maduggan", 236 | "avatar_url": "https://avatars.githubusercontent.com/u/53565912?v=4", 237 | "profile": "https://github.com/maduggan", 238 | "contributions": [ 239 | "code" 240 | ] 241 | } 242 | ], 243 | "contributorsPerLine": 7, 244 | "projectName": "subspace", 245 | "projectOwner": "subspacecommunity", 246 | "repoType": "github", 247 | "repoHost": "https://github.com", 248 | "skipCi": true 249 | } 250 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome! If you're looking to help, this document is a great place to start! 4 | 5 | ## Building the project 6 | 7 | To build Subspace from source, you will need [Go 1.13](https://golang.org/dl/) or later installed. 8 | 9 | ```sh 10 | git clone git@github.com:subspacecommunity/subspace && cd subspace 11 | make 12 | 13 | # run 14 | ./subspace 15 | ``` 16 | 17 | ## Git workflow 18 | 19 | ```sh 20 | username=$your username 21 | # add your remote/upstream 22 | git remote add $username git@github.com:$username/subspace.git 23 | 24 | # update from origin/master 25 | git pull --rebase 26 | 27 | # create a branch 28 | git checkout -b my_feature 29 | 30 | # push changes from my_feature to your fork. 31 | # -u, --set-upstream set upstream for git pull/status 32 | git push -u $username 33 | ``` 34 | 35 | ## Go Resources 36 | 37 | A few helpful resources for getting started with Go: 38 | 39 | - [Writing, building, installing, and testing Go code](https://www.youtube.com/watch?v=XCsL89YtqCs) 40 | - [Resources for new Go programmers](http://dave.cheney.net/resources-for-new-go-programmers) 41 | - [How I start](https://howistart.org/posts/go/1) 42 | - [How to write Go code](https://golang.org/doc/code.html) 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | to: 2 | cc: @subspacecommunity/subspace-maintainers 3 | related to: 4 | resolves: 5 | 6 | ## Background 7 | 8 | Reason for the change 9 | 10 | ### Changes 11 | 12 | * Summary of changes 13 | * ... 14 | 15 | 16 | ## Testing 17 | 18 | Steps for how this change was tested and verified 19 | 20 | 21 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # Config for https://github.com/apps/release-drafter 2 | name-template: 'v$NEXT_MINOR_VERSION (🌈 Insert Release Name)' 3 | tag-template: 'v$NEXT_MINOR_VERSION' 4 | categories: 5 | - title: 'Dependency Updates' 6 | label: 'dependencies' 7 | - title: '🚀 Features' 8 | labels: 9 | - 'feature' 10 | - 'enhancement' 11 | - title: '🐛 Bug Fixes' 12 | labels: 13 | - 'fix' 14 | - 'bugfix' 15 | - 'bug' 16 | exclude-labels: 17 | - 'skip-changelog' 18 | autolabeler: 19 | - label: 'documentation' 20 | files: 21 | - '*.md' 22 | branch: 23 | - '/docs{0,1}\/.+/' 24 | title: 25 | - '/docs/i' 26 | - label: 'bug' 27 | branch: 28 | - '/fix\/.+/' 29 | title: 30 | - '/fix/i' 31 | - label: 'enhancement' 32 | branch: 33 | - '/feature\/.+/' 34 | body: 35 | - '/feature{0,1}\/.+/' 36 | title: 37 | - '/feature/i' 38 | - label: 'refactor' 39 | branch: 40 | - '/refactor\/.+/' 41 | title: 42 | - '/refactor/i' 43 | template: | 44 | ## Changes 45 | $CHANGES 46 | ## Upgrading 47 | To upgrade, swap your docker-tags 48 | ```diff 49 | - subspacecommunity/subspace:$PREVIOUS_TAG 50 | + subspacecommunity/subspace:$NEXT_MINOR_VERSION 51 | ``` 52 | -------------------------------------------------------------------------------- /.github/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/.github/screenshot1.png -------------------------------------------------------------------------------- /.github/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/.github/screenshot2.png -------------------------------------------------------------------------------- /.github/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/.github/screenshot3.png -------------------------------------------------------------------------------- /.github/screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/.github/screenshot4.png -------------------------------------------------------------------------------- /.github/workflows/draft-release.yml: -------------------------------------------------------------------------------- 1 | name: Draft Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v5 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | vendor/ 16 | 17 | # Subspace 18 | /subspace 19 | bindata.go 20 | 21 | # User Specific 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16-alpine as build 2 | 3 | RUN apk add --no-cache \ 4 | git \ 5 | make 6 | 7 | WORKDIR /src 8 | 9 | COPY Makefile ./ 10 | # go.mod and go.sum if exists 11 | COPY go.* ./ 12 | COPY cmd/ ./cmd 13 | COPY web ./web 14 | 15 | ARG BUILD_VERSION=unknown 16 | 17 | ENV GODEBUG="netdns=go http2server=0" 18 | 19 | RUN make build BUILD_VERSION=${BUILD_VERSION} 20 | 21 | FROM alpine:3.13.4 22 | LABEL maintainer="github.com/subspacecommunity/subspace" 23 | 24 | COPY --from=build /src/subspace /usr/bin/subspace 25 | COPY entrypoint.sh /usr/local/bin/entrypoint.sh 26 | COPY bin/my_init /sbin/my_init 27 | 28 | ENV DEBIAN_FRONTEND noninteractive 29 | 30 | RUN chmod +x /usr/bin/subspace /usr/local/bin/entrypoint.sh /sbin/my_init 31 | 32 | RUN apk add --no-cache \ 33 | iproute2 \ 34 | iptables \ 35 | ip6tables \ 36 | dnsmasq \ 37 | socat \ 38 | wireguard-tools \ 39 | runit 40 | 41 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh" ] 42 | 43 | CMD [ "/sbin/my_init" ] 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 viewscreen Cloud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | .PHONY: help clean 3 | 4 | BINDATA=${GOPATH}/bin/go-bindata 5 | BUILD_VERSION?=unknown 6 | 7 | 8 | help: ## Display this help message and exit 9 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 10 | 11 | build: clean bindata.go ## Build the binary 12 | @echo "Compiling subspace..." 13 | @CGO_ENABLED=0 \ 14 | go build -v --compiler gc --ldflags "-extldflags -static -s -w -X main.version=${BUILD_VERSION}" -o subspace ./cmd/subspace \ 15 | && rm cmd/subspace/bindata.go 16 | @echo "+++ subspace compiled" 17 | 18 | clean: ## Remove old binaries 19 | rm -f subspace cmd/subspace/bindata.go 20 | 21 | bindata.go: $(BINDATA) 22 | @echo "Creating bindata.go..." 23 | @go-bindata -o cmd/subspace/bindata.go --prefix "web/" --pkg main web/... 24 | @echo "+++ bindata.go created" 25 | 26 | $(BINDATA): 27 | go get github.com/kevinburke/go-bindata/go-bindata 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Subspace - A simple WireGuard VPN server GUI 2 | 3 | 4 | [![All Contributors](https://img.shields.io/badge/all_contributors-26-orange.svg?style=flat-square)](#contributors-) 5 | 6 | 7 | [![](https://images.microbadger.com/badges/image/subspacecommunity/subspace.svg)](https://microbadger.com/images/subspacecommunity/subspace "Get your own image badge on microbadger.com") [![](https://images.microbadger.com/badges/version/subspacecommunity/subspace.svg)](https://microbadger.com/images/subspacecommunity/subspace "Get your own version badge on microbadger.com") 8 | 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/subspacecommunity/subspace)](https://goreportcard.com/report/github.com/subspacecommunity/subspace) 10 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=subspacecommunity_subspace&metric=alert_status)](https://sonarcloud.io/dashboard?id=subspacecommunity_subspace) 11 | [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=subspacecommunity_subspace&metric=ncloc)](https://sonarcloud.io/dashboard?id=subspacecommunity_subspace) 12 | [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=subspacecommunity_subspace&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=subspacecommunity_subspace) 13 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=subspacecommunity_subspace&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=subspacecommunity_subspace) 14 | 15 | --- 16 | 17 | **IMPORTANT NOTICE**: shorthand dockerhub tags are **OUTDATED**. Please use long tags with the correct Arch for your CPU architecture, i.e. avoid `latest` or `1.5.0` tags and use instead `amd64-v1.5.0` for 64bit processors, `arm32v7-v1.5.0` for ARM v7, and so on. See [issue](https://github.com/subspacecommunity/subspace/issues/193). 18 | 19 | --- 20 | 21 | - [Subspace - A simple WireGuard VPN server GUI](#subspace---a-simple-wireguard-vpn-server-gui) 22 | - [Slack](#slack) 23 | - [Screenshots](#screenshots) 24 | - [Features](#features) 25 | - [Contributing](#contributing) 26 | - [Setup](#setup) 27 | - [1. Get a server](#1-get-a-server) 28 | - [2. Add a DNS record](#2-add-a-dns-record) 29 | - [3. Enable Let's Encrypt](#3-enable-lets-encrypt) 30 | - [Usage](#usage) 31 | - [Command Line Options](#command-line-options) 32 | - [Environment Variable Options](#environment-variable-options) 33 | - [Run as a Docker container](#run-as-a-docker-container) 34 | - [Install WireGuard on the host](#install-wireguard-on-the-host) 35 | - [Docker-Compose Example](#docker-compose-example) 36 | - [Updating the container image](#updating-the-container-image) 37 | - [Contributors ✨](#contributors-) 38 | 39 | ## Slack 40 | 41 | Join the slack community over at the [gophers](https://invite.slack.golangbridge.org/) workspace. Our Channel is `#subspace` which can be used to ask general questions in regards to subspace where the community can assist where possible. 42 | 43 | ## Screenshots 44 | 45 | 46 | | | | | 47 | | :--------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------: | --- | 48 | | ![Screenshot 1](https://raw.githubusercontent.com/subspacecommunity/subspace/master/.github/screenshot1.png) | ![Screenshot 3](https://raw.githubusercontent.com/subspacecommunity/subspace/master/.github/screenshot3.png) | 49 | | ![Screenshot 2](https://raw.githubusercontent.com/subspacecommunity/subspace/master/.github/screenshot2.png) | ![Screenshot 4](https://raw.githubusercontent.com/subspacecommunity/subspace/master/.github/screenshot4.png) | 50 | 51 | ## Features 52 | 53 | - **WireGuard VPN Protocol** 54 | - The most modern and fastest VPN protocol. 55 | - **Single Sign-On (SSO) with SAML** 56 | - Support for SAML providers like G Suite and Okta. 57 | - **Add Devices** 58 | - Connect from Mac OS X, Windows, Linux, Android, or iOS. 59 | - **Remove Devices** 60 | - Removes client key and disconnects client. 61 | - **Auto-generated Configs** 62 | - Each client gets a unique downloadable config file. 63 | - Generates a QR code for easy importing on iOS and Android. 64 | 65 | ## Contributing 66 | 67 | See the [CONTRIBUTING](https://raw.githubusercontent.com/subspacecommunity/subspace/master/.github/CONTRIBUTING.md) page for additional info. 68 | 69 | ## Setup 70 | 71 | ### 1. Get a server 72 | 73 | **Recommended Specs** 74 | 75 | - Type: VPS or dedicated 76 | - Distribution: Ubuntu 16.04 (Xenial), 18.04 (Bionic) or 20.04 (Focal) 77 | - Memory: 512MB or greater 78 | 79 | ### 2. Add a DNS record 80 | 81 | Create a DNS `A` record in your domain pointing to your server's IP address. 82 | 83 | **Example:** `subspace.example.com A 172.16.1.1` 84 | 85 | ### 3. Enable Let's Encrypt 86 | 87 | Subspace runs a TLS ("SSL") https server on port 443/tcp. It also runs a standard web server on port 80/tcp to redirect clients to the secure server. 88 | Port 80/tcp is required for LetsEncrypt verification. 89 | 90 | **Requirements** 91 | 92 | - Your server must have a publicly resolvable DNS record. 93 | - Your server must be reachable over the internet on ports 80/tcp, 443/tcp and 51820/udp (Default WireGuard port, user changeable). 94 | 95 | ### Usage 96 | 97 | **Example usage:** 98 | 99 | ```bash 100 | $ subspace --http-host subspace.example.com 101 | ``` 102 | 103 | #### Command Line Options 104 | 105 | | flag | default | description | 106 | | :-------------: | :-----: | :------------------------------------------------------------------------------------------------------------------------ | 107 | | `http-host` | | REQUIRED: The host to listen on and set cookies for | 108 | | `backlink` | `/` | OPTIONAL: The page to set the home button to | 109 | | `datadir` | `/data` | OPTIONAL: The directory to store data such as the WireGuard configuration files | 110 | | `debug` | | OPTIONAL: Place subspace into debug mode for verbose log output | 111 | | `http-addr` | `:80` | OPTIONAL: HTTP listen address | 112 | | `http-insecure` | | OPTIONAL: enable session cookies for http and remove redirect to https | 113 | | `letsencrypt` | `true` | OPTIONAL: Whether or not to use a LetsEncrypt certificate | 114 | | `theme` | `green` | OPTIONAL: The theme to use, please refer to [semantic-ui](https://semantic-ui.com/usage/theming.html) for accepted colors | 115 | | `version` | | Display version of `subspace` and exit | 116 | | `help` | | Display help and exit | 117 | 118 | #### Environment Variable Options 119 | 120 | | variable | default | description | 121 | |-----------------------------|---------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| 122 | | `SUBSPACE_IPV4_POOL` | `10.99.97.0/24` | IPv4 Subnet to use as WireGuard subnet | 123 | | `SUBSPACE_IPV6_POOL` | `fd00::10:97:0/112` | IPv6 Subnet to use as WireGuard subnet | 124 | | `SUBSPACE_NAMESERVERS` | `1.1.1.1,1.0.0.1` | Nameservers to use, by-default those of Cloudflare. | 125 | | `SUBSPACE_LETSENCRYPT` | `1` | Whether or not to use a LetsEncrypt certificate | 126 | | `SUBSPACE_HTTP_ADDR` | `:80` | HTTP listen address | 127 | | `SUBSPACE_HTTP_INSECURE` | `false` | Enable session cookies for http and remove redirect to https | 128 | | `SUBSPACE_LISTENPORT` | `51820` | Port for WireGuard to listen on | 129 | | `SUBSPACE_ENDPOINT_HOST` | `httpHost` | The host to listen on for the webserver, if it differs from the VPN GW. | 130 | | `SUBSPACE_ALLOWED_IPS` | `0.0.0.0/0, ::/0` | Comma-separated list of IP's / subnets that are routed via WireGuard. By default everything is routed. | 131 | | `SUBSPACE_IPV4_NAT_ENABLED` | `true` | Whether to enable NAT routing for IPv4 | 132 | | `SUBSPACE_IPV6_NAT_ENABLED` | `true` | Whether to enable NAT routing for IPv6 | 133 | | `SUBSPACE_THEME` | `green` | The theme to use, please refer to [semantic-ui](https://semantic-ui.com/usage/theming.html) for accepted colors | 134 | | `SUBSPACE_BACKLINK` | `/` | The page to set the home button to | 135 | | `SUBSPACE_DISABLE_DNS` | `false` | Whether to disable DNS so the client uses their own configured DNS server(s). Consider disabling DNS server, if supporting international VPN clients | 136 | | `SUBSPACE_PERSISTENT_KEEPALIVE` | `0` | Whether PersistentKeepalive should be enabled for clients (seconds) | 137 | 138 | ### Run as a Docker container 139 | 140 | #### Install WireGuard on the host 141 | 142 | The container expects WireGuard to be installed on the host. The official image is `subspacecommunity/subspace`. 143 | 144 | ```bash 145 | apt-get update 146 | apt-get install -y wireguard 147 | 148 | # Remove dnsmasq because it will run inside the container. 149 | apt-get remove -y dnsmasq 150 | 151 | # Disable systemd-resolved listener if it blocks port 53. 152 | echo "DNSStubListener=no" >> /etc/systemd/resolved.conf 153 | systemctl restart systemd-resolved 154 | 155 | # Set Cloudfare DNS server. 156 | echo nameserver 1.1.1.1 > /etc/resolv.conf 157 | echo nameserver 1.0.0.1 >> /etc/resolv.conf 158 | 159 | # Load modules. 160 | modprobe wireguard 161 | modprobe iptable_nat 162 | modprobe ip6table_nat 163 | 164 | # Enable modules when rebooting. 165 | echo "wireguard" > /etc/modules-load.d/wireguard.conf 166 | echo "iptable_nat" > /etc/modules-load.d/iptable_nat.conf 167 | echo "ip6table_nat" > /etc/modules-load.d/ip6table_nat.conf 168 | 169 | # Check if systemd-modules-load service is active. 170 | systemctl status systemd-modules-load.service 171 | 172 | # Enable IP forwarding. 173 | sysctl -w net.ipv4.ip_forward=1 174 | sysctl -w net.ipv6.conf.all.forwarding=1 175 | 176 | ``` 177 | 178 | Follow the official Docker install instructions: [Get Docker CE for Ubuntu](https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/) 179 | 180 | Make sure to change the `--env SUBSPACE_HTTP_HOST` to your publicly accessible domain name. 181 | 182 | If you want to run the vpn on a different domain as the http host you can set `--env SUBSPACE_ENDPOINT_HOST` 183 | 184 | Use `--env SUBSPACE_DISABLE_DNS=1` to make subspace generate WireGuard configs without the `DNS` option, preserving the user's DNS servers. 185 | 186 | ```bash 187 | 188 | # Your data directory should be bind-mounted as `/data` inside the container using the `--volume` flag. 189 | $ mkdir /data 190 | 191 | docker create \ 192 | --name subspace \ 193 | --restart always \ 194 | --network host \ 195 | --cap-add NET_ADMIN \ 196 | --volume /data:/data \ 197 | # Optional directory for mounting dnsmasq configurations 198 | --volume /etc/dnsmasq.d:/etc/dnsmasq.d \ 199 | --env SUBSPACE_HTTP_HOST="subspace.example.com" \ 200 | # Optional variable to change upstream DNS provider 201 | --env SUBSPACE_NAMESERVERS="1.1.1.1,8.8.8.8" \ 202 | # Optional variable to change WireGuard Listenport 203 | --env SUBSPACE_LISTENPORT="51820" \ 204 | # Optional variables to change IPv4/v6 prefixes 205 | --env SUBSPACE_IPV4_POOL="10.99.97.0/24" \ 206 | --env SUBSPACE_IPV6_POOL="fd00::10:97:0/64" \ 207 | # Optional variables to change IPv4/v6 Gateway 208 | --env SUBSPACE_IPV4_GW="10.99.97.1" \ 209 | --env SUBSPACE_IPV6_GW="fd00::10:97:1" \ 210 | # Optional variable to enable or disable IPv6 NAT 211 | --env SUBSPACE_IPV6_NAT_ENABLED=1 \ 212 | # Optional variable to disable DNS server. Enabled by default. 213 | # consider disabling DNS server, if supporting international VPN clients 214 | --env SUBSPACE_DISABLE_DNS=0 \ 215 | # Optional variable to change PersistentKeepalive 216 | --env SUBSPACE_PERSISTENT_KEEPALIVE=20 \ 217 | subspacecommunity/subspace:latest 218 | 219 | $ sudo docker start subspace 220 | 221 | $ sudo docker logs subspace 222 | 223 | 224 | 225 | ``` 226 | 227 | #### Docker-Compose Example 228 | 229 | ``` 230 | version: "3.3" 231 | services: 232 | subspace: 233 | image: subspacecommunity/subspace:latest 234 | container_name: subspace 235 | volumes: 236 | - /opt/docker/subspace:/data 237 | - /opt/docker/dnsmasq:/etc/dnsmasq.d 238 | restart: always 239 | environment: 240 | - SUBSPACE_HTTP_HOST=subspace.example.org 241 | - SUBSPACE_LETSENCRYPT=true 242 | - SUBSPACE_HTTP_INSECURE=false 243 | - SUBSPACE_HTTP_ADDR=":80" 244 | - SUBSPACE_NAMESERVERS=1.1.1.1,8.8.8.8 245 | - SUBSPACE_LISTENPORT=51820 246 | - SUBSPACE_IPV4_POOL=10.99.97.0/24 247 | - SUBSPACE_IPV6_POOL=fd00::10:97:0/64 248 | - SUBSPACE_IPV4_GW=10.99.97.1 249 | - SUBSPACE_IPV6_GW=fd00::10:97:1 250 | - SUBSPACE_IPV6_NAT_ENABLED=1 251 | - SUBSPACE_DISABLE_DNS=0 252 | - SUBSPACE_PERSISTENT_KEEPALIVE=20 253 | cap_add: 254 | - NET_ADMIN 255 | network_mode: "host" 256 | ``` 257 | 258 | #### Updating the container image 259 | 260 | Pull the latest image, remove the container, and re-create the container as explained above. 261 | 262 | ```bash 263 | # Pull the latest image 264 | $ sudo docker pull subspacecommunity/subspace 265 | 266 | # Stop the container 267 | $ sudo docker stop subspace 268 | 269 | # Remove the container (data is stored on the mounted volume) 270 | $ sudo docker rm subspace 271 | 272 | # Re-create and start the container 273 | $ sudo docker create ... (see above) 274 | ``` 275 | 276 | ## Contributors ✨ 277 | 278 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 |

Duncan Mac-Vicar P.

💻

Valentin Ouvrard

💻

Adrián González Barbosa

💻

Gavin

💻

Lucas Servén Marín

💻

Jack

💻

Sam SIU

💻

Elliot Westlake

💻

Clément Péron

📖

Selva

📖

Frank

💻

Gian Lazzarini

📖

Nham Le

💻

Sinan Mohd

📖

Sigurður Guðbrandsson

💻

vojta7

💻

Fabian

📖

Miroslav Shubernetskiy

📖

dovreshef

💻

Freekers

💻

Leander

📖

Gabriel Chamon Araujo

💻

Alex Flores

💻

Jared P Bostic

💻

Qasim Mehmood

💻

maduggan

💻
319 | 320 | 321 | 322 | 323 | 324 | 325 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 326 | -------------------------------------------------------------------------------- /bin/my_init: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | shutdown() { 4 | echo "shutting down container" 5 | 6 | # first shutdown any service started by runit 7 | for _srv in $(ls -1 /etc/service); do 8 | sv force-stop $_srv 9 | done 10 | 11 | # shutdown runsvdir command 12 | kill -HUP $RUNSVDIR 13 | wait $RUNSVDIR 14 | 15 | # give processes time to stop 16 | sleep 0.5 17 | 18 | # kill any other processes still running in the container 19 | for _pid in $(ps -eo pid | grep -v PID | tr -d ' ' | grep -v '^1$' | head -n -6); do 20 | timeout -t 5 /bin/sh -c "kill $_pid && wait $_pid || kill -9 $_pid" 21 | done 22 | exit 23 | } 24 | 25 | # store enviroment variables 26 | export > /etc/envvars 27 | 28 | PATH=/usr/local/bin:/usr/local/sbin:/bin:/sbin:/usr/bin:/usr/sbin:/usr/X11R6/bin 29 | 30 | exec env - PATH=$PATH runsvdir -P /etc/service & 31 | 32 | RUNSVDIR=$! 33 | echo "Started runsvdir, PID is $RUNSVDIR" 34 | echo "wait for processes to start...." 35 | 36 | sleep 5 37 | for _srv in $(ls -1 /etc/service); do 38 | sv status $_srv 39 | done 40 | 41 | # catch shutdown signals 42 | trap shutdown SIGTERM SIGHUP SIGQUIT SIGINT 43 | wait $RUNSVDIR 44 | 45 | shutdown 46 | -------------------------------------------------------------------------------- /cmd/subspace/assets.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "github.com/jteeuwen/go-bindata" 5 | ) 6 | 7 | //go:generate go run github.com/jteeuwen/go-bindata/go-bindata --pkg main static/... templates/... email/... 8 | -------------------------------------------------------------------------------- /cmd/subspace/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "crypto/x509/pkix" 8 | "encoding/json" 9 | "encoding/pem" 10 | "errors" 11 | "fmt" 12 | "io/ioutil" 13 | "math/big" 14 | "os" 15 | "path/filepath" 16 | "regexp" 17 | "sort" 18 | "sync" 19 | "time" 20 | 21 | "github.com/pquerna/otp/totp" 22 | ) 23 | 24 | var ( 25 | ErrProfileNotFound = errors.New("profile not found") 26 | ErrUserNotFound = errors.New("user not found") 27 | ErrUserDeleteFailed = errors.New("delete failed because user has devices") 28 | ) 29 | 30 | type User struct { 31 | ID string `json:"id"` 32 | Email string `json:"email"` 33 | Admin bool `json:"admin"` 34 | Created time.Time `json:"created"` 35 | 36 | Profiles []Profile `json:"-"` 37 | } 38 | 39 | type Profile struct { 40 | ID string `json:"id"` 41 | UserID string `json:"user"` 42 | Name string `json:"name"` 43 | Platform string `json:"platform"` 44 | Number int `json:"number"` 45 | Created time.Time `json:"created"` 46 | 47 | User User `json:"-"` 48 | } 49 | 50 | func (p Profile) NameClean() string { 51 | return regexp.MustCompile(`[^a-zA-Z0-9]`).ReplaceAllString(p.Name, "") 52 | } 53 | 54 | func (p Profile) WireGuardConfigPath() string { 55 | return fmt.Sprintf("%s/wireguard/clients/%s.conf", datadir, p.ID) 56 | } 57 | 58 | func (p Profile) WireGuardConfigName() string { 59 | return "wg0.conf" 60 | } 61 | 62 | type Info struct { 63 | Email string `json:"email"` 64 | Password []byte `json:"password"` 65 | Secret string `json:"secret"` 66 | TotpKey string `json:"totp_key"` 67 | Configured bool `json:"configure"` 68 | Domain string `json:"domain"` 69 | HashKey string `json:"hash_key"` 70 | BlockKey string `json:"block_key"` 71 | SAML struct { 72 | IDPMetadata string `json:"idp_metadata"` 73 | PrivateKey []byte `json:"private_key"` 74 | Certificate []byte `json:"certificate"` 75 | } `json:"saml"` 76 | Mail struct { 77 | From string `json:"from"` 78 | Server string `json:"server"` 79 | Port int `json:"port"` 80 | Username string `json:"username"` 81 | Password string `json:"password"` 82 | } `json:"mail"` 83 | } 84 | 85 | type Config struct { 86 | mu sync.RWMutex 87 | filename string 88 | 89 | Info *Info `json:"info"` 90 | 91 | Profiles []*Profile `json:"profiles"` 92 | Users []*User `json:"users"` 93 | 94 | Modified time.Time `json:"modified"` 95 | } 96 | 97 | func NewConfig(filename string) (*Config, error) { 98 | filename = filepath.Join(datadir, filename) 99 | c := &Config{filename: filename} 100 | b, err := ioutil.ReadFile(filename) 101 | 102 | // Create new config with defaults 103 | if os.IsNotExist(err) { 104 | c.Info = &Info{ 105 | Email: "null", 106 | HashKey: RandomString(32), 107 | BlockKey: RandomString(32), 108 | } 109 | return c, c.generateSAMLKeyPair() 110 | } 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | // Open existing config 116 | if err := json.Unmarshal(b, c); err != nil { 117 | return nil, fmt.Errorf("invalid config %q: %s", filename, err) 118 | } 119 | 120 | return c, nil 121 | } 122 | 123 | func (c *Config) Lock() { 124 | c.mu.Lock() 125 | } 126 | 127 | func (c *Config) Unlock() { 128 | c.mu.Unlock() 129 | } 130 | 131 | func (c *Config) RLock() { 132 | c.mu.RLock() 133 | } 134 | 135 | func (c *Config) RUnlock() { 136 | c.mu.RUnlock() 137 | } 138 | 139 | func (c *Config) generateSAMLKeyPair() error { 140 | // Generate private key. 141 | key, err := rsa.GenerateKey(rand.Reader, 2048) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | // Generate the certificate. 147 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 148 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | tmpl := x509.Certificate{ 154 | NotBefore: time.Now(), 155 | NotAfter: time.Now().AddDate(5, 0, 0), 156 | SerialNumber: serialNumber, 157 | Subject: pkix.Name{ 158 | CommonName: httpHost, 159 | Organization: []string{"Subspace"}, 160 | }, 161 | BasicConstraintsValid: true, 162 | } 163 | 164 | cert, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | // Generate private key PEM block. 170 | c.Info.SAML.PrivateKey = pem.EncodeToMemory(&pem.Block{ 171 | Type: "RSA PRIVATE KEY", 172 | Bytes: x509.MarshalPKCS1PrivateKey(key), 173 | }) 174 | 175 | // Generate certificate PEM block. 176 | c.Info.SAML.Certificate = pem.EncodeToMemory(&pem.Block{ 177 | Type: "CERTIFICATE", 178 | Bytes: cert, 179 | }) 180 | return c.save() 181 | } 182 | 183 | func (c *Config) DeleteProfile(id string) error { 184 | c.Lock() 185 | defer c.Unlock() 186 | 187 | var profiles []*Profile 188 | for _, p := range c.Profiles { 189 | if p.ID == id { 190 | continue 191 | } 192 | profiles = append(profiles, p) 193 | } 194 | c.Profiles = profiles 195 | return c.save() 196 | } 197 | 198 | func (c *Config) UpdateProfile(id string, fn func(*Profile) error) error { 199 | c.Lock() 200 | defer c.Unlock() 201 | p, err := c.findProfile(id) 202 | if err != nil { 203 | return err 204 | } 205 | if err := fn(p); err != nil { 206 | return err 207 | } 208 | return c.save() 209 | } 210 | 211 | func (c *Config) AddProfile(userID, name, platform string) (Profile, error) { 212 | c.Lock() 213 | defer c.Unlock() 214 | 215 | id := RandomString(16) 216 | 217 | number := 2 // MUST start at 2 218 | for _, p := range c.Profiles { 219 | if p.Number >= number { 220 | number = p.Number + 1 221 | } 222 | } 223 | profile := Profile{ 224 | ID: id, 225 | UserID: userID, 226 | Name: name, 227 | Platform: platform, 228 | Number: number, 229 | Created: time.Now(), 230 | } 231 | c.Profiles = append(c.Profiles, &profile) 232 | return profile, c.save() 233 | } 234 | 235 | func (c *Config) FindProfile(id string) (Profile, error) { 236 | c.RLock() 237 | defer c.RUnlock() 238 | u, err := c.findProfile(id) 239 | if err != nil { 240 | return Profile{}, err 241 | } 242 | return *u, nil 243 | } 244 | 245 | func (c *Config) findProfile(id string) (*Profile, error) { 246 | for _, u := range c.Profiles { 247 | if u.ID == id { 248 | return u, nil 249 | } 250 | } 251 | return nil, ErrProfileNotFound 252 | } 253 | 254 | func (c *Config) ListProfilesByUser(id string) (profiles []Profile) { 255 | c.RLock() 256 | defer c.RUnlock() 257 | for _, p := range c.listProfilesByUser(id) { 258 | profiles = append(profiles, *p) 259 | } 260 | return 261 | } 262 | 263 | func (c *Config) listProfilesByUser(id string) (profiles []*Profile) { 264 | for _, p := range c.Profiles { 265 | if p.UserID != id { 266 | continue 267 | } 268 | profiles = append(profiles, p) 269 | } 270 | sort.Slice(profiles, func(i, j int) bool { return profiles[i].Created.After(profiles[j].Created) }) 271 | return 272 | } 273 | 274 | func (c *Config) ListProfiles() (profiles []Profile) { 275 | c.RLock() 276 | defer c.RUnlock() 277 | for _, p := range c.listProfiles() { 278 | profiles = append(profiles, *p) 279 | } 280 | return 281 | } 282 | 283 | func (c *Config) listProfiles() (profiles []*Profile) { 284 | profiles = append(profiles, c.Profiles...) 285 | sort.Slice(profiles, func(i, j int) bool { return profiles[i].Created.After(profiles[j].Created) }) 286 | return 287 | } 288 | 289 | func (c *Config) FindInfo() Info { 290 | c.RLock() 291 | defer c.RUnlock() 292 | return *c.Info 293 | } 294 | 295 | func (c *Config) UpdateInfo(fn func(*Info) error) error { 296 | c.Lock() 297 | defer c.Unlock() 298 | if err := fn(c.Info); err != nil { 299 | return err 300 | } 301 | return c.save() 302 | } 303 | 304 | func (c *Config) DeleteUser(id string) error { 305 | c.Lock() 306 | defer c.Unlock() 307 | 308 | if len(c.listProfilesByUser(id)) > 0 { 309 | return ErrUserDeleteFailed 310 | } 311 | 312 | var users []*User 313 | for _, p := range c.Users { 314 | if p.ID == id { 315 | continue 316 | } 317 | users = append(users, p) 318 | } 319 | c.Users = users 320 | return c.save() 321 | } 322 | 323 | func (c *Config) UpdateUser(id string, fn func(*User) error) error { 324 | c.Lock() 325 | defer c.Unlock() 326 | p, err := c.findUser(id) 327 | if err != nil { 328 | return err 329 | } 330 | if err := fn(p); err != nil { 331 | return err 332 | } 333 | return c.save() 334 | } 335 | 336 | func (c *Config) AddUser(email string) (User, error) { 337 | if user, err := c.FindUserByEmail(email); err == nil { 338 | return user, nil 339 | } 340 | 341 | c.Lock() 342 | defer c.Unlock() 343 | 344 | id := RandomString(16) 345 | user := User{ 346 | ID: id, 347 | Email: email, 348 | Created: time.Now(), 349 | } 350 | c.Users = append(c.Users, &user) 351 | return user, c.save() 352 | } 353 | 354 | func (c *Config) FindUserByEmail(email string) (User, error) { 355 | c.RLock() 356 | defer c.RUnlock() 357 | u, err := c.findUserByEmail(email) 358 | if err != nil { 359 | return User{}, err 360 | } 361 | user := *u 362 | user.Profiles = []Profile{} 363 | for _, p := range c.listProfilesByUser(user.ID) { 364 | user.Profiles = append(user.Profiles, *p) 365 | } 366 | return user, nil 367 | } 368 | 369 | func (c *Config) findUserByEmail(email string) (*User, error) { 370 | for _, u := range c.Users { 371 | if u.Email == email { 372 | return u, nil 373 | } 374 | } 375 | return nil, ErrUserNotFound 376 | } 377 | 378 | func (c *Config) FindUser(id string) (User, error) { 379 | c.RLock() 380 | defer c.RUnlock() 381 | u, err := c.findUser(id) 382 | if err != nil { 383 | return User{}, err 384 | } 385 | user := *u 386 | user.Profiles = []Profile{} 387 | for _, p := range c.listProfilesByUser(user.ID) { 388 | user.Profiles = append(user.Profiles, *p) 389 | } 390 | return *u, nil 391 | } 392 | 393 | func (c *Config) findUser(id string) (*User, error) { 394 | for _, u := range c.Users { 395 | if u.ID == id { 396 | return u, nil 397 | } 398 | } 399 | return nil, ErrUserNotFound 400 | } 401 | 402 | func (c *Config) ListUsers() (users []User) { 403 | c.RLock() 404 | defer c.RUnlock() 405 | for _, u := range c.listUsers() { 406 | user := *u 407 | user.Profiles = []Profile{} 408 | for _, p := range c.listProfilesByUser(user.ID) { 409 | user.Profiles = append(user.Profiles, *p) 410 | } 411 | users = append(users, user) 412 | } 413 | return 414 | } 415 | 416 | func (c *Config) listUsers() (users []*User) { 417 | users = append(users, c.Users...) 418 | sort.Slice(users, func(i, j int) bool { return users[i].Created.After(users[j].Created) }) 419 | return 420 | } 421 | 422 | func (c *Config) save() error { 423 | b, err := json.MarshalIndent(c, "", " ") 424 | if err != nil { 425 | return err 426 | } 427 | return Overwrite(c.filename, b, 0644) 428 | } 429 | 430 | func (c *Config) ResetTotp() error { 431 | c.Lock() 432 | defer c.Unlock() 433 | 434 | c.Info.TotpKey = "" 435 | 436 | if err := c.save(); err != nil { 437 | return err 438 | } 439 | 440 | return c.GenerateTOTP() 441 | } 442 | 443 | func (c *Config) GenerateTOTP() error { 444 | key, err := totp.Generate( 445 | totp.GenerateOpts{ 446 | Issuer: httpHost, 447 | AccountName: c.Info.Email, 448 | }, 449 | ) 450 | if err != nil { 451 | return err 452 | } 453 | 454 | tempTotpKey = key 455 | 456 | return nil 457 | } 458 | -------------------------------------------------------------------------------- /cmd/subspace/handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image/png" 7 | "io/ioutil" 8 | "net/http" 9 | "os" 10 | "regexp" 11 | "strings" 12 | 13 | "github.com/crewjam/saml/samlsp" 14 | "github.com/julienschmidt/httprouter" 15 | "github.com/pquerna/otp/totp" 16 | "golang.org/x/crypto/bcrypt" 17 | 18 | qrcode "github.com/skip2/go-qrcode" 19 | ) 20 | 21 | var ( 22 | validEmail = regexp.MustCompile(`^[ -~]+@[ -~]+$`) 23 | validPassword = regexp.MustCompile(`^[ -~]{6,200}$`) 24 | validString = regexp.MustCompile(`^[ -~]{1,200}$`) 25 | maxProfiles = 250 26 | ) 27 | 28 | func getEnv(key, fallback string) string { 29 | if value, ok := os.LookupEnv(key); ok { 30 | return value 31 | } 32 | return fallback 33 | } 34 | 35 | // Handles the sign in part separately from the SAML 36 | func ssoHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 37 | session, err := samlSP.Session.GetSession(r) 38 | if session != nil { 39 | http.Redirect(w, r, "/", http.StatusFound) 40 | return 41 | } 42 | if err == samlsp.ErrNoSession { 43 | logger.Debugf("SSO: HandleStartAuthFlow") 44 | samlSP.HandleStartAuthFlow(w, r) 45 | return 46 | } 47 | 48 | logger.Debugf("SSO: unable to get session") 49 | samlSP.OnError(w, r, err) 50 | return 51 | } 52 | 53 | // Handles the SAML part separately from sign in 54 | func samlHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 55 | if samlSP == nil { 56 | logger.Warnf("SAML is not configured") 57 | http.NotFound(w, r) 58 | return 59 | } 60 | logger.Debugf("SSO: samlSP.ServeHTTP") 61 | samlSP.ServeHTTP(w, r) 62 | } 63 | 64 | func wireguardQRConfigHandler(w *Web) { 65 | profile, err := config.FindProfile(w.ps.ByName("profile")) 66 | if err != nil { 67 | http.NotFound(w.w, w.r) 68 | return 69 | } 70 | if !w.Admin && profile.UserID != w.User.ID { 71 | Error(w.w, fmt.Errorf("failed to view config: permission denied")) 72 | return 73 | } 74 | 75 | b, err := ioutil.ReadFile(profile.WireGuardConfigPath()) 76 | if err != nil { 77 | Error(w.w, err) 78 | return 79 | } 80 | 81 | img, err := qrcode.Encode(string(b), qrcode.Medium, 256) 82 | if err != nil { 83 | Error(w.w, err) 84 | return 85 | } 86 | 87 | w.w.Header().Set("Content-Type", "image/png") 88 | w.w.Header().Set("Content-Length", fmt.Sprintf("%d", len(img))) 89 | if _, err := w.w.Write(img); err != nil { 90 | Error(w.w, err) 91 | return 92 | } 93 | } 94 | 95 | func wireguardConfigHandler(w *Web) { 96 | profile, err := config.FindProfile(w.ps.ByName("profile")) 97 | if err != nil { 98 | http.NotFound(w.w, w.r) 99 | return 100 | } 101 | if !w.Admin && profile.UserID != w.User.ID { 102 | Error(w.w, fmt.Errorf("failed to view config: permission denied")) 103 | return 104 | } 105 | 106 | b, err := ioutil.ReadFile(profile.WireGuardConfigPath()) 107 | if err != nil { 108 | Error(w.w, err) 109 | return 110 | } 111 | 112 | w.w.Header().Set("Content-Disposition", "attachment; filename="+profile.WireGuardConfigName()) 113 | w.w.Header().Set("Content-Type", "application/x-wireguard-profile") 114 | w.w.Header().Set("Content-Length", fmt.Sprintf("%d", len(b))) 115 | if _, err := w.w.Write(b); err != nil { 116 | Error(w.w, err) 117 | return 118 | } 119 | } 120 | 121 | func configureHandler(w *Web) { 122 | if config.FindInfo().Configured { 123 | w.Redirect("/?error=configured") 124 | return 125 | } 126 | 127 | if w.r.Method == "GET" { 128 | w.HTML() 129 | return 130 | } 131 | 132 | email := strings.ToLower(strings.TrimSpace(w.r.FormValue("email"))) 133 | emailConfirm := strings.ToLower(strings.TrimSpace(w.r.FormValue("email_confirm"))) 134 | password := w.r.FormValue("password") 135 | 136 | if !validEmail.MatchString(email) || !validPassword.MatchString(password) || email != emailConfirm { 137 | w.Redirect("/configure?error=invalid") 138 | return 139 | } 140 | 141 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 142 | if err != nil { 143 | w.Redirect("/forgot?error=bcrypt") 144 | return 145 | } 146 | config.UpdateInfo(func(i *Info) error { 147 | i.Email = email 148 | i.Password = hashedPassword 149 | i.Configured = true 150 | return nil 151 | }) 152 | 153 | if err := w.SigninSession(true, ""); err != nil { 154 | Error(w.w, err) 155 | return 156 | } 157 | w.Redirect("/settings?success=configured") 158 | } 159 | 160 | func forgotHandler(w *Web) { 161 | if w.r.Method == "GET" { 162 | w.HTML() 163 | return 164 | } 165 | 166 | email := strings.ToLower(strings.TrimSpace(w.r.FormValue("email"))) 167 | secret := w.r.FormValue("secret") 168 | password := w.r.FormValue("password") 169 | 170 | if email != "" && !validEmail.MatchString(email) { 171 | w.Redirect("/forgot?error=invalid") 172 | return 173 | } 174 | if secret != "" && !validString.MatchString(secret) { 175 | w.Redirect("/forgot?error=invalid") 176 | return 177 | } 178 | if email != "" && secret != "" && !validPassword.MatchString(password) { 179 | w.Redirect("/forgot?error=invalid&email=%s&secret=%s", email, secret) 180 | return 181 | } 182 | 183 | if email != config.FindInfo().Email { 184 | w.Redirect("/forgot?error=invalid") 185 | return 186 | } 187 | 188 | if secret == "" { 189 | secret = config.FindInfo().Secret 190 | if secret == "" { 191 | secret = RandomString(32) 192 | config.UpdateInfo(func(i *Info) error { 193 | if i.Secret == "" { 194 | i.Secret = secret 195 | } 196 | return nil 197 | }) 198 | } 199 | 200 | go func() { 201 | if err := mailer.Forgot(email, secret); err != nil { 202 | logger.Error(err) 203 | } 204 | }() 205 | 206 | w.Redirect("/forgot?success=forgot") 207 | return 208 | } 209 | 210 | if secret != config.FindInfo().Secret { 211 | w.Redirect("/forgot?error=invalid") 212 | return 213 | } 214 | 215 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 216 | if err != nil { 217 | w.Redirect("/forgot?error=bcrypt") 218 | return 219 | } 220 | config.UpdateInfo(func(i *Info) error { 221 | i.Password = hashedPassword 222 | i.Secret = "" 223 | return nil 224 | }) 225 | 226 | if err := w.SigninSession(true, ""); err != nil { 227 | Error(w.w, err) 228 | return 229 | } 230 | w.Redirect("/") 231 | } 232 | 233 | func signoutHandler(w *Web) { 234 | w.SignoutSession() 235 | w.Redirect("/signin") 236 | } 237 | 238 | func signinHandler(w *Web) { 239 | if w.r.Method == "GET" { 240 | w.HTML() 241 | return 242 | } 243 | 244 | email := strings.ToLower(strings.TrimSpace(w.r.FormValue("email"))) 245 | password := w.r.FormValue("password") 246 | passcode := w.r.FormValue("totp") 247 | 248 | if email != config.FindInfo().Email { 249 | w.Redirect("/signin?error=invalid") 250 | return 251 | } 252 | 253 | if err := bcrypt.CompareHashAndPassword(config.FindInfo().Password, []byte(password)); err != nil { 254 | w.Redirect("/signin?error=invalid") 255 | return 256 | } 257 | 258 | if config.FindInfo().TotpKey != "" && !totp.Validate(passcode, config.FindInfo().TotpKey) { 259 | // Totp has been configured and the provided code doesn't match 260 | w.Redirect("/signin?error=invalid") 261 | return 262 | } 263 | 264 | if err := w.SigninSession(true, ""); err != nil { 265 | Error(w.w, err) 266 | return 267 | } 268 | 269 | w.Redirect("/") 270 | } 271 | 272 | func totpQRHandler(w *Web) { 273 | if !w.Admin { 274 | Error(w.w, fmt.Errorf("failed to view config: permission denied")) 275 | return 276 | } 277 | 278 | if config.Info.TotpKey != "" { 279 | // TOTP is already configured, don't allow the current one to be leaked 280 | w.Redirect("/") 281 | return 282 | } 283 | 284 | var buf bytes.Buffer 285 | img, err := tempTotpKey.Image(200, 200) 286 | if err != nil { 287 | Error(w.w, err) 288 | return 289 | } 290 | 291 | png.Encode(&buf, img) 292 | 293 | w.w.Header().Set("Content-Type", "image/png") 294 | w.w.Header().Set("Content-Length", fmt.Sprintf("%d", len(buf.Bytes()))) 295 | if _, err := w.w.Write(buf.Bytes()); err != nil { 296 | Error(w.w, err) 297 | return 298 | } 299 | 300 | } 301 | 302 | func userEditHandler(w *Web) { 303 | userID := w.ps.ByName("user") 304 | if userID == "" { 305 | userID = w.r.FormValue("user") 306 | } 307 | user, err := config.FindUser(userID) 308 | if err != nil { 309 | http.NotFound(w.w, w.r) 310 | return 311 | } 312 | if !w.Admin { 313 | Error(w.w, fmt.Errorf("failed to edit user: permission denied")) 314 | return 315 | } 316 | 317 | if w.r.Method == "GET" { 318 | w.TargetUser = user 319 | w.Profiles = config.ListProfilesByUser(user.ID) 320 | w.HTML() 321 | return 322 | } 323 | 324 | if w.User.ID == user.ID { 325 | w.Redirect("/user/edit/%s", user.ID) 326 | return 327 | } 328 | 329 | admin := w.r.FormValue("admin") == "yes" 330 | 331 | config.UpdateUser(user.ID, func(u *User) error { 332 | u.Admin = admin 333 | return nil 334 | }) 335 | 336 | w.Redirect("/user/edit/%s?success=edituser", user.ID) 337 | } 338 | 339 | func userDeleteHandler(w *Web) { 340 | userID := w.ps.ByName("user") 341 | if userID == "" { 342 | userID = w.r.FormValue("user") 343 | } 344 | user, err := config.FindUser(userID) 345 | if err != nil { 346 | http.NotFound(w.w, w.r) 347 | return 348 | } 349 | if !w.Admin { 350 | Error(w.w, fmt.Errorf("failed to delete user: permission denied")) 351 | return 352 | } 353 | if w.User.ID == user.ID { 354 | w.Redirect("/user/edit/%s?error=deleteuser", user.ID) 355 | return 356 | } 357 | 358 | if w.r.Method == "GET" { 359 | w.TargetUser = user 360 | w.HTML() 361 | return 362 | } 363 | 364 | for _, profile := range config.ListProfilesByUser(user.ID) { 365 | if err := deleteProfile(profile); err != nil { 366 | logger.Errorf("delete profile failed: %s", err) 367 | w.Redirect("/profile/delete?error=deleteprofile") 368 | return 369 | } 370 | } 371 | 372 | if err := config.DeleteUser(user.ID); err != nil { 373 | Error(w.w, err) 374 | return 375 | } 376 | w.Redirect("/?success=deleteuser") 377 | } 378 | 379 | func profileAddHandler(w *Web) { 380 | if !w.Admin && w.User.ID == "" { 381 | http.NotFound(w.w, w.r) 382 | return 383 | } 384 | 385 | name := strings.TrimSpace(w.r.FormValue("name")) 386 | platform := strings.TrimSpace(w.r.FormValue("platform")) 387 | admin := w.r.FormValue("admin") == "yes" 388 | 389 | if platform == "" { 390 | platform = "other" 391 | } 392 | 393 | if name == "" { 394 | w.Redirect("/?error=profilename") 395 | return 396 | } 397 | 398 | var userID string 399 | if admin { 400 | userID = "" 401 | } else { 402 | userID = w.User.ID 403 | } 404 | 405 | if len(config.ListProfiles()) >= maxProfiles { 406 | w.Redirect("/?error=addprofile") 407 | return 408 | } 409 | 410 | profile, err := config.AddProfile(userID, name, platform) 411 | if err != nil { 412 | logger.Warn(err) 413 | w.Redirect("/?error=addprofile") 414 | return 415 | } 416 | 417 | ipv4Pref := "10.99.97." 418 | if pref := getEnv("SUBSPACE_IPV4_PREF", "nil"); pref != "nil" { 419 | ipv4Pref = pref 420 | } 421 | ipv4Gw := "10.99.97.1" 422 | if gw := getEnv("SUBSPACE_IPV4_GW", "nil"); gw != "nil" { 423 | ipv4Gw = gw 424 | } 425 | ipv4Cidr := "24" 426 | if cidr := getEnv("SUBSPACE_IPV4_CIDR", "nil"); cidr != "nil" { 427 | ipv4Cidr = cidr 428 | } 429 | ipv6Pref := "fd00::10:97:" 430 | if pref := getEnv("SUBSPACE_IPV6_PREF", "nil"); pref != "nil" { 431 | ipv6Pref = pref 432 | } 433 | ipv6Gw := "fd00::10:97:1" 434 | if gw := getEnv("SUBSPACE_IPV6_GW", "nil"); gw != "nil" { 435 | ipv6Gw = gw 436 | } 437 | ipv6Cidr := "64" 438 | if cidr := getEnv("SUBSPACE_IPV6_CIDR", "nil"); cidr != "nil" { 439 | ipv6Cidr = cidr 440 | } 441 | listenport := "51820" 442 | if port := getEnv("SUBSPACE_LISTENPORT", "nil"); port != "nil" { 443 | listenport = port 444 | } 445 | endpointHost := httpHost 446 | if eh := getEnv("SUBSPACE_ENDPOINT_HOST", "nil"); eh != "nil" { 447 | endpointHost = eh 448 | } 449 | allowedips := "0.0.0.0/0, ::/0" 450 | if ips := getEnv("SUBSPACE_ALLOWED_IPS", "nil"); ips != "nil" { 451 | allowedips = ips 452 | } 453 | ipv4Enabled := true 454 | if enable := getEnv("SUBSPACE_IPV4_NAT_ENABLED", "1"); enable == "0" { 455 | ipv4Enabled = false 456 | } 457 | ipv6Enabled := true 458 | if enable := getEnv("SUBSPACE_IPV6_NAT_ENABLED", "1"); enable == "0" { 459 | ipv6Enabled = false 460 | } 461 | disableDNS := false 462 | if shouldDisableDNS := getEnv("SUBSPACE_DISABLE_DNS", "0"); shouldDisableDNS == "1" { 463 | disableDNS = true 464 | } 465 | persistentKeepalive := "0" 466 | if keepalive := getEnv("SUBSPACE_PERSISTENT_KEEPALIVE", "nil"); keepalive != "nil" { 467 | persistentKeepalive = keepalive 468 | } 469 | 470 | script := ` 471 | cd {{$.Datadir}}/wireguard 472 | wg_private_key="$(wg genkey)" 473 | wg_public_key="$(echo $wg_private_key | wg pubkey)" 474 | 475 | wg set wg0 peer ${wg_public_key} allowed-ips {{if .Ipv4Enabled}}{{$.IPv4Pref}}{{$.Profile.Number}}/32{{end}}{{if .Ipv6Enabled}}{{if .Ipv4Enabled}},{{end}}{{$.IPv6Pref}}{{$.Profile.Number}}/128{{end}} 476 | 477 | cat <peers/{{$.Profile.ID}}.conf 478 | [Peer] 479 | PublicKey = ${wg_public_key} 480 | AllowedIPs = {{if .Ipv4Enabled}}{{$.IPv4Pref}}{{$.Profile.Number}}/32{{end}}{{if .Ipv6Enabled}}{{if .Ipv4Enabled}},{{end}}{{$.IPv6Pref}}{{$.Profile.Number}}/128{{end}} 481 | WGPEER 482 | 483 | cat <clients/{{$.Profile.ID}}.conf 484 | [Interface] 485 | PrivateKey = ${wg_private_key} 486 | {{- if not .DisableDNS }} 487 | DNS = {{if .Ipv4Enabled}}{{$.IPv4Gw}}{{end}}{{if .Ipv6Enabled}}{{if .Ipv4Enabled}},{{end}}{{$.IPv6Gw}}{{end}} 488 | {{- end }} 489 | Address = {{if .Ipv4Enabled}}{{$.IPv4Pref}}{{$.Profile.Number}}/{{$.IPv4Cidr}}{{end}}{{if .Ipv6Enabled}}{{if .Ipv4Enabled}},{{end}}{{$.IPv6Pref}}{{$.Profile.Number}}/{{$.IPv6Cidr}}{{end}} 490 | 491 | [Peer] 492 | PublicKey = $(cat server.public) 493 | 494 | Endpoint = {{$.EndpointHost}}:{{$.Listenport}} 495 | AllowedIPs = {{$.AllowedIPS}} 496 | PersistentKeepalive = {{$.PersistentKeepalive}} 497 | WGCLIENT 498 | ` 499 | _, err = bash(script, struct { 500 | Profile Profile 501 | EndpointHost string 502 | Datadir string 503 | IPv4Gw string 504 | IPv6Gw string 505 | IPv4Pref string 506 | IPv6Pref string 507 | IPv4Cidr string 508 | IPv6Cidr string 509 | Listenport string 510 | AllowedIPS string 511 | Ipv4Enabled bool 512 | Ipv6Enabled bool 513 | DisableDNS bool 514 | PersistentKeepalive string 515 | }{ 516 | profile, 517 | endpointHost, 518 | datadir, 519 | ipv4Gw, 520 | ipv6Gw, 521 | ipv4Pref, 522 | ipv6Pref, 523 | ipv4Cidr, 524 | ipv6Cidr, 525 | listenport, 526 | allowedips, 527 | ipv4Enabled, 528 | ipv6Enabled, 529 | disableDNS, 530 | persistentKeepalive, 531 | }) 532 | if err != nil { 533 | logger.Warn(err) 534 | f, _ := os.Create("/tmp/error.txt") 535 | errstr := fmt.Sprintln(err) 536 | f.WriteString(errstr) 537 | w.Redirect("/?error=addprofile") 538 | return 539 | } 540 | 541 | w.Redirect("/profile/connect/%s?success=addprofile", profile.ID) 542 | } 543 | 544 | func profileConnectHandler(w *Web) { 545 | profile, err := config.FindProfile(w.ps.ByName("profile")) 546 | if err != nil { 547 | http.NotFound(w.w, w.r) 548 | return 549 | } 550 | if !w.Admin && profile.UserID != w.User.ID { 551 | Error(w.w, fmt.Errorf("failed to view profile: permission denied")) 552 | return 553 | } 554 | w.Profile = profile 555 | w.HTML() 556 | } 557 | 558 | func profileDeleteHandler(w *Web) { 559 | profileID := w.ps.ByName("profile") 560 | if profileID == "" { 561 | profileID = w.r.FormValue("profile") 562 | } 563 | profile, err := config.FindProfile(profileID) 564 | if err != nil { 565 | http.NotFound(w.w, w.r) 566 | return 567 | } 568 | if !w.Admin && profile.UserID != w.User.ID { 569 | Error(w.w, fmt.Errorf("failed to delete profile: permission denied")) 570 | return 571 | } 572 | 573 | if w.r.Method == "GET" { 574 | w.Profile = profile 575 | w.HTML() 576 | return 577 | } 578 | if err := deleteProfile(profile); err != nil { 579 | logger.Errorf("delete profile failed: %s", err) 580 | w.Redirect("/profile/delete?error=deleteprofile") 581 | return 582 | } 583 | if w.Admin { 584 | w.Redirect("/user/edit/%s?success=deleteprofile", profile.UserID) 585 | return 586 | } 587 | w.Redirect("/?success=deleteprofile") 588 | } 589 | 590 | func indexHandler(w *Web) { 591 | if w.User.ID != "" { 592 | w.TargetProfiles = config.ListProfilesByUser(w.User.ID) 593 | } 594 | if w.Admin { 595 | w.Profiles = config.ListProfilesByUser("") 596 | w.Users = config.ListUsers() 597 | } else { 598 | w.Profiles = config.ListProfilesByUser(w.User.ID) 599 | } 600 | w.HTML() 601 | } 602 | 603 | func settingsHandler(w *Web) { 604 | if !w.Admin { 605 | Error(w.w, fmt.Errorf("settings: permission denied")) 606 | return 607 | } 608 | 609 | if w.r.Method == "GET" { 610 | w.HTML() 611 | return 612 | } 613 | 614 | email := strings.ToLower(strings.TrimSpace(w.r.FormValue("email"))) 615 | samlMetadata := strings.TrimSpace(w.r.FormValue("saml_metadata")) 616 | 617 | currentPassword := w.r.FormValue("current_password") 618 | newPassword := w.r.FormValue("new_password") 619 | 620 | resetTotp := w.r.FormValue("reset_totp") 621 | totpCode := w.r.FormValue("totp_code") 622 | 623 | config.UpdateInfo(func(i *Info) error { 624 | i.SAML.IDPMetadata = samlMetadata 625 | i.Email = email 626 | return nil 627 | }) 628 | 629 | // Configure SAML if metadata is present. 630 | if len(samlMetadata) > 0 { 631 | if err := configureSAML(); err != nil { 632 | logger.Warnf("configuring SAML failed: %s", err) 633 | w.Redirect("/settings?error=saml") 634 | } 635 | } else { 636 | samlSP = nil 637 | } 638 | 639 | if currentPassword != "" || newPassword != "" { 640 | if !validPassword.MatchString(newPassword) { 641 | w.Redirect("/settings?error=invalid") 642 | return 643 | } 644 | 645 | if err := bcrypt.CompareHashAndPassword(config.FindInfo().Password, []byte(currentPassword)); err != nil { 646 | w.Redirect("/settings?error=invalid") 647 | return 648 | } 649 | 650 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) 651 | if err != nil { 652 | w.Redirect("/settings?error=bcrypt") 653 | return 654 | } 655 | 656 | config.UpdateInfo(func(i *Info) error { 657 | i.Password = hashedPassword 658 | return nil 659 | }) 660 | } 661 | 662 | if resetTotp == "true" { 663 | err := config.ResetTotp() 664 | if err != nil { 665 | w.Redirect("/settings?error=totp") 666 | return 667 | } 668 | 669 | w.Redirect("/settings?success=totp") 670 | return 671 | } 672 | 673 | if config.Info.TotpKey == "" && totpCode != "" { 674 | if !totp.Validate(totpCode, tempTotpKey.Secret()) { 675 | w.Redirect("/settings?error=totp") 676 | return 677 | } 678 | config.Info.TotpKey = tempTotpKey.Secret() 679 | config.save() 680 | } 681 | 682 | w.Redirect("/settings?success=settings") 683 | } 684 | 685 | func helpHandler(w *Web) { 686 | w.HTML() 687 | } 688 | 689 | // 690 | // Helpers 691 | // 692 | func deleteProfile(profile Profile) error { 693 | script := ` 694 | # WireGuard 695 | cd {{$.Datadir}}/wireguard 696 | peerid=$(cat peers/{{$.Profile.ID}}.conf | awk '/PublicKey/ { printf("%s", $3) }' ) 697 | wg set wg0 peer $peerid remove 698 | rm peers/{{$.Profile.ID}}.conf 699 | rm clients/{{$.Profile.ID}}.conf 700 | ` 701 | output, err := bash(script, struct { 702 | Datadir string 703 | Profile Profile 704 | }{ 705 | datadir, 706 | profile, 707 | }) 708 | if err != nil { 709 | return fmt.Errorf("delete profile failed %s %s", err, output) 710 | } 711 | return config.DeleteProfile(profile.ID) 712 | } 713 | -------------------------------------------------------------------------------- /cmd/subspace/mailer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "math/rand" 7 | "net" 8 | "strings" 9 | "text/template" 10 | "time" 11 | 12 | humanize "github.com/dustin/go-humanize" 13 | gomail "gopkg.in/gomail.v2" 14 | ) 15 | 16 | func init() { 17 | rand.Seed(time.Now().Unix()) 18 | } 19 | 20 | type Mailer struct{} 21 | 22 | func NewMailer() *Mailer { 23 | return &Mailer{} 24 | } 25 | 26 | func (m *Mailer) Forgot(email, secret string) error { 27 | subject := "Password reset link" 28 | 29 | params := struct { 30 | HTTPHost string 31 | Email string 32 | Secret string 33 | }{ 34 | httpHost, 35 | email, 36 | secret, 37 | } 38 | return m.sendmail("forgot.html", email, subject, params) 39 | } 40 | 41 | func (m *Mailer) sendmail(tmpl, to, subject string, data interface{}) error { 42 | body, err := m.Render(tmpl, data) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | cfg := config.FindInfo().Mail 48 | 49 | from := cfg.From 50 | server := cfg.Server 51 | port := cfg.Port 52 | username := cfg.Username 53 | password := cfg.Password 54 | 55 | if from == "" { 56 | from = fmt.Sprintf("Subspace ", httpHost) 57 | } 58 | 59 | if server == "" { 60 | addrs, err := net.LookupMX(strings.Split(to, "@")[1]) 61 | if err != nil || len(addrs) == 0 { 62 | return err 63 | } 64 | server = strings.TrimSuffix(addrs[rand.Intn(len(addrs))].Host, ".") 65 | port = 25 66 | } 67 | 68 | d := gomail.NewDialer(server, port, username, password) 69 | s, err := d.Dial() 70 | if err != nil { 71 | return err 72 | } 73 | logger.Infof("sendmail from %q to %q %q via %s:%d", from, to, subject, server, port) 74 | 75 | msg := gomail.NewMessage() 76 | msg.SetHeader("From", from) 77 | msg.SetHeader("To", to) 78 | msg.SetHeader("Subject", subject) 79 | msg.SetBody("text/html", body) 80 | 81 | if err := gomail.Send(s, msg); err != nil { 82 | return fmt.Errorf("failed sending email: %s", err) 83 | } 84 | return nil 85 | } 86 | 87 | func (m *Mailer) Render(target string, data interface{}) (string, error) { 88 | t := template.New(target).Funcs(template.FuncMap{ 89 | "time": humanize.Time, 90 | }) 91 | for _, filename := range AssetNames() { 92 | if !strings.HasPrefix(filename, "email/") { 93 | continue 94 | } 95 | name := strings.TrimPrefix(filename, "email/") 96 | b, err := Asset(filename) 97 | if err != nil { 98 | return "", err 99 | } 100 | 101 | var tmpl *template.Template 102 | if name == t.Name() { 103 | tmpl = t 104 | } else { 105 | tmpl = t.New(name) 106 | } 107 | if _, err := tmpl.Parse(string(b)); err != nil { 108 | return "", err 109 | } 110 | } 111 | var b bytes.Buffer 112 | if err := t.Execute(&b, data); err != nil { 113 | return "", err 114 | } 115 | return b.String(), nil 116 | } 117 | -------------------------------------------------------------------------------- /cmd/subspace/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/tls" 8 | "crypto/x509" 9 | "encoding/xml" 10 | "flag" 11 | "fmt" 12 | "net" 13 | "net/http" 14 | "net/url" 15 | "os" 16 | "path/filepath" 17 | "strings" 18 | "time" 19 | 20 | "github.com/julienschmidt/httprouter" 21 | "github.com/pquerna/otp" 22 | 23 | "github.com/crewjam/saml" 24 | "github.com/crewjam/saml/samlsp" 25 | "github.com/gorilla/securecookie" 26 | log "github.com/sirupsen/logrus" 27 | "golang.org/x/crypto/acme/autocert" 28 | ) 29 | 30 | var ( 31 | // Flags 32 | cli = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 33 | 34 | // datadir 35 | datadir string 36 | 37 | // The version is set by the build command. 38 | version string 39 | 40 | // httpd 41 | httpAddr string 42 | httpHost string 43 | httpPrefix string 44 | 45 | // Insecure http cookies (only recommended for internal LANs/VPNs) 46 | httpInsecure bool 47 | 48 | // backlink 49 | backlink string 50 | 51 | // show version 52 | showVersion bool 53 | 54 | // show help 55 | showHelp bool 56 | 57 | // debug logging 58 | debug bool 59 | 60 | // Let's Encrypt 61 | letsencrypt bool 62 | 63 | // securetoken 64 | securetoken *securecookie.SecureCookie 65 | 66 | // logger 67 | logger = log.New() 68 | 69 | // config 70 | config *Config 71 | 72 | // mailer 73 | mailer = NewMailer() 74 | 75 | // SAML 76 | samlSP *samlsp.Middleware 77 | 78 | // Error page HTML 79 | errorPageHTML = `Error

An error has occurred

` 80 | 81 | // theme 82 | semanticTheme string 83 | 84 | // Totp 85 | tempTotpKey *otp.Key 86 | ) 87 | 88 | func init() { 89 | cli.StringVar(&datadir, "datadir", "/data", "data dir") 90 | cli.StringVar(&backlink, "backlink", "/", "backlink (optional)") 91 | cli.StringVar(&httpHost, "http-host", "", "HTTP host") 92 | cli.StringVar(&httpAddr, "http-addr", ":80", "HTTP listen address") 93 | cli.BoolVar(&httpInsecure, "http-insecure", false, "enable sessions cookies for http (no https) not recommended") 94 | cli.BoolVar(&letsencrypt, "letsencrypt", true, "enable TLS using Let's Encrypt on port 443") 95 | cli.BoolVar(&showVersion, "version", false, "display version and exit") 96 | cli.BoolVar(&showHelp, "help", false, "display help and exit") 97 | cli.BoolVar(&debug, "debug", false, "debug mode") 98 | cli.StringVar(&semanticTheme, "theme", "green", "Semantic-ui theme to use") 99 | } 100 | 101 | func main() { 102 | var err error 103 | 104 | cli.Parse(os.Args[1:]) 105 | usage := func(msg string) { 106 | if msg != "" { 107 | fmt.Fprintf(os.Stderr, "ERROR: %s\n", msg) 108 | } 109 | fmt.Fprintf(os.Stderr, "Usage: %s --http-host subspace.example.com\n\n", os.Args[0]) 110 | cli.PrintDefaults() 111 | } 112 | 113 | if showHelp { 114 | usage("Help info") 115 | os.Exit(0) 116 | } 117 | 118 | if showVersion { 119 | fmt.Printf("Subspace %s\n", version) 120 | os.Exit(0) 121 | } 122 | 123 | // http host 124 | if httpHost == "" { 125 | usage("--http-host flag is required") 126 | os.Exit(1) 127 | } 128 | 129 | // debug logging 130 | logger.Out = os.Stdout 131 | if debug { 132 | logger.SetLevel(log.DebugLevel) 133 | } 134 | logger.Debugf("debug logging is enabled") 135 | 136 | // http port 137 | httpIP, httpPort, err := net.SplitHostPort(httpAddr) 138 | if err != nil { 139 | usage("invalid --http-addr: " + err.Error()) 140 | } 141 | 142 | // Clean datadir path. 143 | datadir = filepath.Clean(datadir) 144 | 145 | // config 146 | config, err = NewConfig("config.json") 147 | if err != nil { 148 | logger.Fatal(err) 149 | } 150 | 151 | // TOTP 152 | err = config.GenerateTOTP() 153 | if err != nil { 154 | logger.Fatal(err) 155 | } 156 | 157 | // Secure token 158 | securetoken = securecookie.New([]byte(config.FindInfo().HashKey), []byte(config.FindInfo().BlockKey)) 159 | 160 | // Configure SAML if metadata is present. 161 | if len(config.FindInfo().SAML.IDPMetadata) > 0 { 162 | if err := configureSAML(); err != nil { 163 | logger.Warnf("configuring SAML failed: %s", err) 164 | } 165 | } 166 | 167 | // 168 | // Routes 169 | // 170 | r := &httprouter.Router{} 171 | r.GET("/", Log(WebHandler(indexHandler, "index"))) 172 | r.GET("/help", Log(WebHandler(helpHandler, "help"))) 173 | r.GET("/configure", Log(WebHandler(configureHandler, "configure"))) 174 | r.POST("/configure", Log(WebHandler(configureHandler, "configure"))) 175 | 176 | // SAML 177 | r.GET("/sso", Log(ssoHandler)) 178 | r.GET("/saml/metadata", Log(samlHandler)) 179 | r.POST("/saml/metadata", Log(samlHandler)) 180 | r.GET("/saml/acs", Log(samlHandler)) 181 | r.POST("/saml/acs", Log(samlHandler)) 182 | 183 | r.GET("/totp/image", Log(WebHandler(totpQRHandler, "totp/image"))) 184 | r.GET("/signin", Log(WebHandler(signinHandler, "signin"))) 185 | r.GET("/signout", Log(WebHandler(signoutHandler, "signout"))) 186 | r.POST("/signin", Log(WebHandler(signinHandler, "signin"))) 187 | r.GET("/forgot", Log(WebHandler(forgotHandler, "forgot"))) 188 | r.POST("/forgot", Log(WebHandler(forgotHandler, "forgot"))) 189 | 190 | r.GET("/settings", Log(WebHandler(settingsHandler, "settings"))) 191 | r.POST("/settings", Log(WebHandler(settingsHandler, "settings"))) 192 | 193 | r.GET("/user/edit/:user", Log(WebHandler(userEditHandler, "user/edit"))) 194 | r.POST("/user/edit", Log(WebHandler(userEditHandler, "user/edit"))) 195 | r.GET("/user/delete/:user", Log(WebHandler(userDeleteHandler, "user/delete"))) 196 | r.POST("/user/delete", Log(WebHandler(userDeleteHandler, "user/delete"))) 197 | 198 | r.GET("/profile/add", Log(WebHandler(profileAddHandler, "profile/add"))) 199 | r.POST("/profile/add", Log(WebHandler(profileAddHandler, "profile/add"))) 200 | r.GET("/profile/connect/:profile", Log(WebHandler(profileConnectHandler, "profile/connect"))) 201 | r.GET("/profile/delete/:profile", Log(WebHandler(profileDeleteHandler, "profile/delete"))) 202 | r.POST("/profile/delete", Log(WebHandler(profileDeleteHandler, "profile/delete"))) 203 | r.GET("/profile/config/wireguard/:profile", Log(WebHandler(wireguardConfigHandler, "profile/config/wireguard"))) 204 | r.GET("/profile/qrconfig/wireguard/:profile", Log(WebHandler(wireguardQRConfigHandler, "profile/qrconfig/wireguard"))) 205 | r.GET("/static/*path", staticHandler) 206 | 207 | // 208 | // Server 209 | // 210 | 211 | httpTimeout := 10 * time.Minute 212 | maxHeaderBytes := 10 * (1024 * 1024) 213 | 214 | // Plain text web server for use behind a reverse proxy. 215 | if !letsencrypt { 216 | httpd := &http.Server{ 217 | Handler: r, 218 | Addr: net.JoinHostPort(httpIP, httpPort), 219 | WriteTimeout: httpTimeout, 220 | ReadTimeout: httpTimeout, 221 | MaxHeaderBytes: maxHeaderBytes, 222 | } 223 | hostport := net.JoinHostPort(httpHost, httpPort) 224 | if httpPort == "80" { 225 | hostport = httpHost 226 | } 227 | logger.Infof("Subspace version: %s %s", version, &url.URL{ 228 | Scheme: "http", 229 | Host: hostport, 230 | Path: httpPrefix, 231 | }) 232 | logger.Fatal(httpd.ListenAndServe()) 233 | } 234 | 235 | // Let's Encrypt TLS mode 236 | 237 | // autocert 238 | certmanager := autocert.Manager{ 239 | Prompt: autocert.AcceptTOS, 240 | Cache: autocert.DirCache(filepath.Join(datadir, "letsencrypt")), 241 | HostPolicy: func(_ context.Context, host string) error { 242 | host = strings.TrimPrefix(host, "www.") 243 | if host == httpHost { 244 | return nil 245 | } 246 | if host == config.FindInfo().Domain { 247 | return nil 248 | } 249 | return fmt.Errorf("autocert: host %q not permitted by HostPolicy", host) 250 | }, 251 | } 252 | 253 | // http redirect to https and Let's Encrypt auth 254 | go func() { 255 | redir := httprouter.New() 256 | redir.GET("/*path", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 257 | r.URL.Scheme = "https" 258 | r.URL.Host = httpHost 259 | http.Redirect(w, r, r.URL.String(), http.StatusFound) 260 | }) 261 | 262 | httpd := &http.Server{ 263 | Handler: certmanager.HTTPHandler(redir), 264 | Addr: net.JoinHostPort(httpIP, "80"), 265 | WriteTimeout: httpTimeout, 266 | ReadTimeout: httpTimeout, 267 | MaxHeaderBytes: maxHeaderBytes, 268 | } 269 | if err := httpd.ListenAndServe(); err != nil { 270 | logger.Fatalf("http server on port 80 failed: %s", err) 271 | } 272 | }() 273 | 274 | // TLS 275 | tlsConfig := tls.Config{ 276 | GetCertificate: certmanager.GetCertificate, 277 | NextProtos: []string{"http/1.1"}, 278 | Rand: rand.Reader, 279 | PreferServerCipherSuites: true, 280 | MinVersion: tls.VersionTLS12, 281 | CipherSuites: []uint16{ 282 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, 283 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 284 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 285 | 286 | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, 287 | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, 288 | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, 289 | }, 290 | } 291 | 292 | // Override default for TLS. 293 | if httpPort == "80" { 294 | httpPort = "443" 295 | httpAddr = net.JoinHostPort(httpIP, httpPort) 296 | } 297 | 298 | httpsd := &http.Server{ 299 | Handler: r, 300 | Addr: httpAddr, 301 | WriteTimeout: httpTimeout, 302 | ReadTimeout: httpTimeout, 303 | MaxHeaderBytes: maxHeaderBytes, 304 | } 305 | 306 | // Enable TCP keep alives on the TLS connection. 307 | tcpListener, err := net.Listen("tcp", httpAddr) 308 | if err != nil { 309 | logger.Fatalf("listen failed: %s", err) 310 | return 311 | } 312 | tlsListener := tls.NewListener(tcpKeepAliveListener{tcpListener.(*net.TCPListener)}, &tlsConfig) 313 | 314 | hostport := net.JoinHostPort(httpHost, httpPort) 315 | if httpPort == "443" { 316 | hostport = httpHost 317 | } 318 | logger.Infof("Subspace version: %s %s", version, &url.URL{ 319 | Scheme: "https", 320 | Host: hostport, 321 | Path: "/", 322 | }) 323 | logger.Fatal(httpsd.Serve(tlsListener)) 324 | } 325 | 326 | type tcpKeepAliveListener struct { 327 | *net.TCPListener 328 | } 329 | 330 | func (l tcpKeepAliveListener) Accept() (c net.Conn, err error) { 331 | tc, err := l.AcceptTCP() 332 | if err != nil { 333 | return 334 | } 335 | tc.SetKeepAlive(true) 336 | tc.SetKeepAlivePeriod(10 * time.Minute) 337 | return tc, nil 338 | } 339 | 340 | func configureSAML() error { 341 | info := config.FindInfo() 342 | 343 | if len(info.SAML.IDPMetadata) == 0 { 344 | return fmt.Errorf("no IDP metadata") 345 | } 346 | entity := &saml.EntityDescriptor{} 347 | err := xml.Unmarshal([]byte(info.SAML.IDPMetadata), entity) 348 | 349 | if err != nil && err.Error() == "expected element type but have " { 350 | entities := &saml.EntitiesDescriptor{} 351 | if err := xml.Unmarshal([]byte(info.SAML.IDPMetadata), entities); err != nil { 352 | return err 353 | } 354 | 355 | err = fmt.Errorf("no entity found with IDPSSODescriptor") 356 | for i, e := range entities.EntityDescriptors { 357 | if len(e.IDPSSODescriptors) > 0 { 358 | entity = &entities.EntityDescriptors[i] 359 | err = nil 360 | } 361 | } 362 | } 363 | if err != nil { 364 | return err 365 | } 366 | 367 | keyPair, err := tls.X509KeyPair(info.SAML.Certificate, info.SAML.PrivateKey) 368 | if err != nil { 369 | return fmt.Errorf("failed to load SAML keypair: %s", err) 370 | } 371 | 372 | keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) 373 | if err != nil { 374 | return fmt.Errorf("failed to parse SAML certificate: %s", err) 375 | } 376 | 377 | rootURL := url.URL{ 378 | Scheme: "https", 379 | Host: httpHost, 380 | Path: "/", 381 | } 382 | 383 | if httpInsecure { 384 | rootURL.Scheme = "http" 385 | } 386 | 387 | newsp, err := samlsp.New(samlsp.Options{ 388 | URL: rootURL, 389 | Key: keyPair.PrivateKey.(*rsa.PrivateKey), 390 | Certificate: keyPair.Leaf, 391 | IDPMetadata: entity, 392 | CookieName: SessionCookieNameSSO, 393 | CookieDomain: httpHost, // TODO: this will break if using a custom domain. 394 | CookieSecure: !httpInsecure, 395 | Logger: logger, 396 | AllowIDPInitiated: true, 397 | }) 398 | if err != nil { 399 | logger.Warnf("failed to configure SAML: %s", err) 400 | samlSP = nil 401 | return fmt.Errorf("failed to configure SAML: %s", err) 402 | } 403 | 404 | newsp.ServiceProvider.AuthnNameIDFormat = saml.EmailAddressNameIDFormat 405 | 406 | samlSP = newsp 407 | logger.Infof("successfully configured SAML") 408 | return nil 409 | } 410 | -------------------------------------------------------------------------------- /cmd/subspace/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "fmt" 9 | "io/ioutil" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "text/template" 14 | "time" 15 | ) 16 | 17 | func RandomString(n int) string { 18 | b := make([]byte, n) 19 | if _, err := rand.Read(b); err != nil { 20 | panic(err) 21 | } 22 | return base64.URLEncoding.EncodeToString(b)[:n] 23 | } 24 | 25 | func Overwrite(filename string, data []byte, perm os.FileMode) error { 26 | f, err := ioutil.TempFile(filepath.Dir(filename), filepath.Base(filename)+".tmp") 27 | if err != nil { 28 | return err 29 | } 30 | if _, err := f.Write(data); err != nil { 31 | return err 32 | } 33 | if err := f.Sync(); err != nil { 34 | return err 35 | } 36 | if err := f.Close(); err != nil { 37 | return err 38 | } 39 | if err := os.Chmod(f.Name(), perm); err != nil { 40 | return err 41 | } 42 | return os.Rename(f.Name(), filename) 43 | } 44 | 45 | func bash(tmpl string, params interface{}) (string, error) { 46 | preamble := ` 47 | set -o nounset 48 | set -o errexit 49 | set -o pipefail 50 | set -o xtrace 51 | ` 52 | t, err := template.New("template").Parse(preamble + tmpl) 53 | if err != nil { 54 | return "", err 55 | } 56 | var script bytes.Buffer 57 | err = t.Execute(&script, params) 58 | if err != nil { 59 | return "", err 60 | } 61 | 62 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 63 | defer cancel() 64 | 65 | output, err := exec.CommandContext(ctx, "/bin/bash", "-c", string(script.Bytes())).CombinedOutput() 66 | if err != nil { 67 | return string(output), fmt.Errorf("command failed: %s\n%s", err, string(output)) 68 | } 69 | return string(output), nil 70 | } 71 | -------------------------------------------------------------------------------- /cmd/subspace/web.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "fmt" 7 | "html/template" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "time" 13 | 14 | "github.com/crewjam/saml" 15 | "github.com/crewjam/saml/samlsp" 16 | "github.com/pquerna/otp" 17 | 18 | "golang.org/x/net/publicsuffix" 19 | 20 | humanize "github.com/dustin/go-humanize" 21 | httprouter "github.com/julienschmidt/httprouter" 22 | ) 23 | 24 | var ( 25 | SessionCookieName = "__subspace_session" 26 | SessionCookieNameSSO = "__subspace_sso_session" 27 | ) 28 | 29 | type Session struct { 30 | Admin bool 31 | UserID string 32 | NotBefore time.Time 33 | NotAfter time.Time 34 | } 35 | 36 | type Web struct { 37 | // Internal 38 | w http.ResponseWriter 39 | r *http.Request 40 | ps httprouter.Params 41 | template string 42 | 43 | // Default 44 | Backlink string 45 | Version string 46 | Request *http.Request 47 | Section string 48 | Time time.Time 49 | Info Info 50 | Admin bool 51 | SAML *samlsp.Middleware 52 | 53 | User User 54 | Users []User 55 | Profile Profile 56 | Profiles []Profile 57 | 58 | TargetUser User 59 | TargetProfiles []Profile 60 | 61 | SemanticTheme string 62 | TempTotpKey *otp.Key 63 | } 64 | 65 | func init() { 66 | gob.Register(Session{}) 67 | } 68 | 69 | func Error(w http.ResponseWriter, err error) { 70 | logger.Error(err) 71 | 72 | w.WriteHeader(http.StatusInternalServerError) 73 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 74 | fmt.Fprintf(w, errorPageHTML+"\n") 75 | } 76 | 77 | func (w *Web) HTML() { 78 | t := template.New(w.template).Funcs(template.FuncMap{ 79 | "hasprefix": strings.HasPrefix, 80 | "hassuffix": strings.HasSuffix, 81 | "add": func(a, b int) int { 82 | return a + b 83 | }, 84 | "bytes": func(n int64) string { 85 | return fmt.Sprintf("%.2f GB", float64(n)/1024/1024/1024) 86 | }, 87 | "date": func(t time.Time) string { 88 | return t.Format(time.UnixDate) 89 | }, 90 | "time": humanize.Time, 91 | "ssoprovider": func() string { 92 | if samlSP == nil { 93 | return "" 94 | } 95 | redirect, err := url.Parse(samlSP.ServiceProvider.GetSSOBindingLocation(saml.HTTPRedirectBinding)) 96 | if err != nil { 97 | logger.Warnf("SSO redirect invalid URL: %s", err) 98 | return "unknown" 99 | } 100 | domain, err := publicsuffix.EffectiveTLDPlusOne(redirect.Host) 101 | if err != nil { 102 | logger.Warnf("SSO redirect invalid URL domain: %s", err) 103 | return "unknown" 104 | } 105 | suffix, icann := publicsuffix.PublicSuffix(domain) 106 | if icann { 107 | suffix = "." + suffix 108 | } 109 | return strings.Title(strings.TrimSuffix(domain, suffix)) 110 | }, 111 | }) 112 | 113 | for _, filename := range AssetNames() { 114 | if !strings.HasPrefix(filename, "templates/") { 115 | continue 116 | } 117 | name := strings.TrimPrefix(filename, "templates/") 118 | b, err := Asset(filename) 119 | if err != nil { 120 | Error(w.w, err) 121 | return 122 | } 123 | 124 | var tmpl *template.Template 125 | if name == t.Name() { 126 | tmpl = t 127 | } else { 128 | tmpl = t.New(name) 129 | } 130 | if _, err := tmpl.Parse(string(b)); err != nil { 131 | Error(w.w, err) 132 | return 133 | } 134 | } 135 | 136 | w.w.Header().Set("Content-Type", "text/html; charset=utf-8") 137 | if err := t.Execute(w.w, w); err != nil { 138 | Error(w.w, err) 139 | return 140 | } 141 | } 142 | 143 | func (w *Web) Redirect(format string, a ...interface{}) { 144 | location := fmt.Sprintf(format, a...) 145 | http.Redirect(w.w, w.r, location, http.StatusFound) 146 | } 147 | 148 | func WebHandler(h func(*Web), section string) httprouter.Handle { 149 | return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 150 | web := &Web{ 151 | w: w, 152 | r: r, 153 | ps: ps, 154 | template: section + ".html", 155 | 156 | Backlink: backlink, 157 | Time: time.Now(), 158 | Version: version, 159 | Request: r, 160 | Section: section, 161 | Info: config.FindInfo(), 162 | SAML: samlSP, 163 | SemanticTheme: semanticTheme, 164 | TempTotpKey: tempTotpKey, 165 | } 166 | 167 | if section == "signin" || section == "forgot" || section == "configure" { 168 | h(web) 169 | return 170 | } 171 | 172 | if !config.FindInfo().Configured { 173 | web.Redirect("/configure") 174 | return 175 | } 176 | 177 | // Has an existing session. 178 | if session, _ := ValidateSession(r); session != nil { 179 | if session.UserID != "" { 180 | user, err := config.FindUser(session.UserID) 181 | if err != nil { 182 | signoutHandler(web) 183 | return 184 | } 185 | web.User = user 186 | web.Admin = user.Admin 187 | } else { 188 | web.Admin = session.Admin 189 | } 190 | h(web) 191 | return 192 | } 193 | 194 | // Needs a new session. 195 | if samlSP != nil { 196 | session, err := samlSP.Session.GetSession(r) 197 | 198 | if err != nil { 199 | logger.Debugf("SAML: Unable to get session from requests: %+v", err) 200 | } 201 | 202 | if session != nil { 203 | r = r.WithContext(samlsp.ContextWithSession(r.Context(), session)) 204 | jwtSessionClaims, ok := session.(samlsp.JWTSessionClaims) 205 | 206 | if !ok { 207 | Error(w, fmt.Errorf("Unable to decode session into JWTSessionClaims")) 208 | return 209 | } 210 | 211 | email := jwtSessionClaims.Subject 212 | if email == "" { 213 | Error(w, fmt.Errorf("SAML token missing email")) 214 | return 215 | } 216 | 217 | logger.Infof("SAML: finding user with email %q", email) 218 | user, err := config.FindUserByEmail(email) 219 | if err != nil && err != ErrUserNotFound { 220 | Error(w, err) 221 | return 222 | } 223 | 224 | if user.ID == "" { 225 | logger.Infof("SAML: creating user with email %q", email) 226 | user, err = config.AddUser(email) 227 | if err != nil { 228 | Error(w, err) 229 | return 230 | } 231 | } 232 | 233 | web.User = user 234 | web.Admin = user.Admin 235 | if err := web.SigninSession(false, user.ID); err != nil { 236 | Error(web.w, err) 237 | return 238 | } 239 | 240 | h(web) 241 | return 242 | } 243 | } 244 | 245 | logger.Warnf("auth: sign in required") 246 | web.Redirect("/signin") 247 | } 248 | } 249 | 250 | func Log(h httprouter.Handle) httprouter.Handle { 251 | return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 252 | start := time.Now() 253 | h(w, r, ps) 254 | ip, _, _ := net.SplitHostPort(r.RemoteAddr) 255 | ua := r.Header.Get("User-Agent") 256 | xff := r.Header.Get("X-Forwarded-For") 257 | xrealip := r.Header.Get("X-Real-IP") 258 | rang := r.Header.Get("Range") 259 | 260 | logger.Infof("%s %q %q %q %q %q %q %s %q %d ms", start, ip, xff, xrealip, ua, rang, r.Referer(), r.Method, r.RequestURI, int64(time.Since(start)/time.Millisecond)) 261 | } 262 | } 263 | 264 | func staticHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 265 | serveAsset(w, r, ps.ByName("path")) 266 | } 267 | 268 | func serveAsset(w http.ResponseWriter, r *http.Request, filename string) { 269 | path := "static" + filename 270 | 271 | b, err := Asset(path) 272 | if err != nil { 273 | http.NotFound(w, r) 274 | return 275 | } 276 | fi, err := AssetInfo(path) 277 | if err != nil { 278 | Error(w, err) 279 | return 280 | } 281 | http.ServeContent(w, r, path, fi.ModTime(), bytes.NewReader(b)) 282 | } 283 | 284 | func ValidateSession(r *http.Request) (*Session, error) { 285 | cookie, err := r.Cookie(SessionCookieName) 286 | if err != nil { 287 | return nil, fmt.Errorf("auth: missing cookie") 288 | } 289 | session := &Session{} 290 | if err := securetoken.Decode(SessionCookieName, cookie.Value, session); err != nil { 291 | return nil, err 292 | } 293 | if time.Now().Before(session.NotBefore) { 294 | return nil, fmt.Errorf("invalid session (before valid)") 295 | } 296 | if time.Now().After(session.NotAfter) { 297 | return nil, fmt.Errorf("invalid session (expired session.NotAfter is %s and now is %s)", session.NotAfter, time.Now()) 298 | } 299 | return session, nil 300 | } 301 | 302 | func (w *Web) SignoutSession() { 303 | if samlSP != nil { 304 | http.SetCookie(w.w, &http.Cookie{ 305 | Name: SessionCookieNameSSO, 306 | Value: "", 307 | Path: "/", 308 | HttpOnly: true, 309 | Domain: httpHost, 310 | Secure: !httpInsecure, 311 | MaxAge: -1, 312 | Expires: time.Unix(1, 0), 313 | }) 314 | } 315 | http.SetCookie(w.w, &http.Cookie{ 316 | Name: SessionCookieName, 317 | Value: "", 318 | Path: "/", 319 | HttpOnly: true, 320 | Domain: httpHost, 321 | Secure: !httpInsecure, 322 | MaxAge: -1, 323 | Expires: time.Unix(1, 0), 324 | }) 325 | } 326 | 327 | func (w *Web) SigninSession(admin bool, userID string) error { 328 | expires := time.Now().Add(12 * time.Hour) 329 | 330 | encoded, err := securetoken.Encode(SessionCookieName, Session{ 331 | Admin: admin, 332 | UserID: userID, 333 | NotBefore: time.Now(), 334 | NotAfter: expires, 335 | }) 336 | if err != nil { 337 | return fmt.Errorf("auth: encoding error: %s", err) 338 | } 339 | http.SetCookie(w.w, &http.Cookie{ 340 | Name: SessionCookieName, 341 | Value: encoded, 342 | Path: "/", 343 | HttpOnly: true, 344 | Domain: httpHost, 345 | Secure: !httpInsecure, 346 | Expires: expires, 347 | }) 348 | return nil 349 | } 350 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -o errexit 3 | set -o nounset 4 | set -o pipefail 5 | set -o xtrace 6 | 7 | # Require environment variables. 8 | if [ -z "${SUBSPACE_HTTP_HOST-}" ]; then 9 | echo "Environment variable SUBSPACE_HTTP_HOST required. Exiting." 10 | exit 1 11 | fi 12 | # Optional environment variables. 13 | if [ -z "${SUBSPACE_BACKLINK-}" ]; then 14 | export SUBSPACE_BACKLINK="/" 15 | fi 16 | 17 | if [ -z "${SUBSPACE_IPV4_POOL-}" ]; then 18 | export SUBSPACE_IPV4_POOL="10.99.97.0/24" 19 | fi 20 | if [ -z "${SUBSPACE_IPV6_POOL-}" ]; then 21 | export SUBSPACE_IPV6_POOL="fd00::10:97:0/112" 22 | fi 23 | if [ -z "${SUBSPACE_NAMESERVERS-}" ]; then 24 | export SUBSPACE_NAMESERVERS="1.1.1.1,1.0.0.1" 25 | fi 26 | 27 | if [ -z "${SUBSPACE_LETSENCRYPT-}" ]; then 28 | export SUBSPACE_LETSENCRYPT="true" 29 | fi 30 | 31 | if [ -z "${SUBSPACE_HTTP_ADDR-}" ]; then 32 | export SUBSPACE_HTTP_ADDR=":80" 33 | fi 34 | 35 | if [ -z "${SUBSPACE_LISTENPORT-}" ]; then 36 | export SUBSPACE_LISTENPORT="51820" 37 | fi 38 | 39 | if [ -z "${SUBSPACE_HTTP_INSECURE-}" ]; then 40 | export SUBSPACE_HTTP_INSECURE="false" 41 | fi 42 | 43 | if [ -z "${SUBSPACE_THEME-}" ]; then 44 | export SUBSPACE_THEME="green" 45 | fi 46 | 47 | export DEBIAN_FRONTEND="noninteractive" 48 | 49 | if [ -z "${SUBSPACE_IPV4_GW-}" ]; then 50 | export SUBSPACE_IPV4_PREF=$(echo ${SUBSPACE_IPV4_POOL-} | cut -d '/' -f1 | sed 's/.0$/./g') 51 | export SUBSPACE_IPV4_GW=$(echo ${SUBSPACE_IPV4_PREF-}1) 52 | 53 | fi 54 | 55 | if [ -z "${SUBSPACE_IPV6_GW-}" ]; then 56 | export SUBSPACE_IPV6_PREF=$(echo ${SUBSPACE_IPV6_POOL-} | cut -d '/' -f1 | sed 's/:0$/:/g') 57 | export SUBSPACE_IPV6_GW=$(echo ${SUBSPACE_IPV6_PREF-}1) 58 | fi 59 | 60 | if [ -z "${SUBSPACE_IPV6_NAT_ENABLED-}" ] || [ "${SUBSPACE_IPV6_NAT_ENABLED}" != "0" ]; then 61 | export SUBSPACE_IPV6_NAT_ENABLED=1 62 | else 63 | export SUBSPACE_IPV6_NAT_ENABLED=0 64 | fi 65 | 66 | if [ -z "${SUBSPACE_IPV4_NAT_ENABLED-}" ] || [ "${SUBSPACE_IPV4_NAT_ENABLED}" != "0" ]; then 67 | export SUBSPACE_IPV4_NAT_ENABLED=1 68 | else 69 | export SUBSPACE_IPV4_NAT_ENABLED=0 70 | fi 71 | 72 | # DNS server is disabled if the flag is not ommited and set to anything other than 0. 73 | if ! [ -z "${SUBSPACE_DISABLE_DNS-}" ] && [ "${SUBSPACE_DISABLE_DNS}" != "0" ]; then 74 | export SUBSPACE_DISABLE_DNS=1 75 | else 76 | export SUBSPACE_DISABLE_DNS=0 77 | fi 78 | 79 | if [ "$SUBSPACE_IPV6_NAT_ENABLED" == "0" ] && [ "$SUBSPACE_IPV4_NAT_ENABLED" == "0" ]; then 80 | echo "One of envionment variables SUBSPACE_IPV6_NAT_ENABLED, SUBSPACE_IPV4_NAT_ENABLED must be set to 1." 81 | echo "Got SUBSPACE_IPV6_NAT_ENABLED=$SUBSPACE_IPV6_NAT_ENABLED, SUBSPACE_IPV4_NAT_ENABLED=$SUBSPACE_IPV4_NAT_ENABLED" 82 | exit 1 83 | fi 84 | 85 | # Empty out inherited nameservers 86 | echo "" >/etc/resolv.conf 87 | # Set DNS servers 88 | echo ${SUBSPACE_NAMESERVERS} | tr "," "\n" | while read -r ns; do echo "nameserver ${ns}" >>/etc/resolv.conf; done 89 | 90 | if [ -z "${SUBSPACE_DISABLE_MASQUERADE-}" ]; then 91 | if [[ ${SUBSPACE_IPV4_NAT_ENABLED} -ne 0 ]]; then 92 | # IPv4 93 | if ! /sbin/iptables -t nat --check POSTROUTING -s ${SUBSPACE_IPV4_POOL} -j MASQUERADE; then 94 | /sbin/iptables -t nat --append POSTROUTING -s ${SUBSPACE_IPV4_POOL} -j MASQUERADE 95 | fi 96 | 97 | if ! /sbin/iptables --check FORWARD -m state --state RELATED,ESTABLISHED -j ACCEPT; then 98 | /sbin/iptables --append FORWARD -m state --state RELATED,ESTABLISHED -j ACCEPT 99 | fi 100 | 101 | if ! /sbin/iptables --check FORWARD -s ${SUBSPACE_IPV4_POOL} -j ACCEPT; then 102 | /sbin/iptables --append FORWARD -s ${SUBSPACE_IPV4_POOL} -j ACCEPT 103 | fi 104 | fi 105 | 106 | if [[ ${SUBSPACE_IPV6_NAT_ENABLED} -ne 0 ]]; then 107 | # IPv6 108 | if ! /sbin/ip6tables -t nat --check POSTROUTING -s ${SUBSPACE_IPV6_POOL} -j MASQUERADE; then 109 | /sbin/ip6tables -t nat --append POSTROUTING -s ${SUBSPACE_IPV6_POOL} -j MASQUERADE 110 | fi 111 | 112 | if ! /sbin/ip6tables --check FORWARD -m state --state RELATED,ESTABLISHED -j ACCEPT; then 113 | /sbin/ip6tables --append FORWARD -m state --state RELATED,ESTABLISHED -j ACCEPT 114 | fi 115 | 116 | if ! /sbin/ip6tables --check FORWARD -s ${SUBSPACE_IPV6_POOL} -j ACCEPT; then 117 | /sbin/ip6tables --append FORWARD -s ${SUBSPACE_IPV6_POOL} -j ACCEPT 118 | fi 119 | fi 120 | fi 121 | 122 | if [[ ${SUBSPACE_IPV4_NAT_ENABLED} -ne 0 ]]; then 123 | # ipv4 - DNS Leak Protection 124 | if ! /sbin/iptables -t nat --check OUTPUT -s ${SUBSPACE_IPV4_POOL} -p udp --dport 53 -j DNAT --to ${SUBSPACE_IPV4_GW}:53; then 125 | /sbin/iptables -t nat --append OUTPUT -s ${SUBSPACE_IPV4_POOL} -p udp --dport 53 -j DNAT --to ${SUBSPACE_IPV4_GW}:53 126 | fi 127 | 128 | if ! /sbin/iptables -t nat --check OUTPUT -s ${SUBSPACE_IPV4_POOL} -p tcp --dport 53 -j DNAT --to ${SUBSPACE_IPV4_GW}:53; then 129 | /sbin/iptables -t nat --append OUTPUT -s ${SUBSPACE_IPV4_POOL} -p tcp --dport 53 -j DNAT --to ${SUBSPACE_IPV4_GW}:53 130 | fi 131 | fi 132 | 133 | if [[ ${SUBSPACE_IPV6_NAT_ENABLED} -ne 0 ]]; then 134 | # ipv6 - DNS Leak Protection 135 | if ! /sbin/ip6tables --wait -t nat --check OUTPUT -s ${SUBSPACE_IPV6_POOL} -p udp --dport 53 -j DNAT --to ${SUBSPACE_IPV6_GW}; then 136 | /sbin/ip6tables --wait -t nat --append OUTPUT -s ${SUBSPACE_IPV6_POOL} -p udp --dport 53 -j DNAT --to ${SUBSPACE_IPV6_GW} 137 | fi 138 | 139 | if ! /sbin/ip6tables --wait -t nat --check OUTPUT -s ${SUBSPACE_IPV6_POOL} -p tcp --dport 53 -j DNAT --to ${SUBSPACE_IPV6_GW}; then 140 | /sbin/ip6tables --wait -t nat --append OUTPUT -s ${SUBSPACE_IPV6_POOL} -p tcp --dport 53 -j DNAT --to ${SUBSPACE_IPV6_GW} 141 | fi 142 | fi 143 | # 144 | # WireGuard (${SUBSPACE_IPV4_POOL}) 145 | # 146 | umask_val=$(umask) 147 | umask 0077 148 | if ! test -d /data/wireguard; then 149 | mkdir /data/wireguard 150 | cd /data/wireguard 151 | 152 | mkdir clients 153 | touch clients/null.conf # So you can cat *.conf safely 154 | mkdir peers 155 | touch peers/null.conf # So you can cat *.conf safely 156 | 157 | # Generate public/private server keys. 158 | wg genkey | tee server.private | wg pubkey >server.public 159 | fi 160 | 161 | cat </data/wireguard/server.conf 162 | [Interface] 163 | PrivateKey = $(cat /data/wireguard/server.private) 164 | ListenPort = ${SUBSPACE_LISTENPORT} 165 | 166 | WGSERVER 167 | cat /data/wireguard/peers/*.conf >>/data/wireguard/server.conf 168 | umask ${umask_val} 169 | [ -f /data/config.json ] && chmod 600 /data/config.json # Special handling of file not created by start-up script 170 | 171 | if ip link show wg0 2>/dev/null; then 172 | ip link del wg0 173 | fi 174 | ip link add wg0 type wireguard 175 | if [[ ${SUBSPACE_IPV4_NAT_ENABLED} -ne 0 ]]; then 176 | export SUBSPACE_IPV4_CIDR=$(echo ${SUBSPACE_IPV4_POOL-} | cut -d '/' -f2) 177 | ip addr add ${SUBSPACE_IPV4_GW}/${SUBSPACE_IPV4_CIDR} dev wg0 178 | fi 179 | if [[ ${SUBSPACE_IPV6_NAT_ENABLED} -ne 0 ]]; then 180 | export SUBSPACE_IPV6_CIDR=$(echo ${SUBSPACE_IPV6_POOL-} | cut -d '/' -f2) 181 | ip addr add ${SUBSPACE_IPV6_GW}/${SUBSPACE_IPV6_CIDR} dev wg0 182 | fi 183 | wg setconf wg0 /data/wireguard/server.conf 184 | ip link set wg0 up 185 | 186 | # dnsmasq service 187 | if [[ ${SUBSPACE_DISABLE_DNS} == "0" ]]; then 188 | DNSMASQ_LISTEN_ADDRESS="127.0.0.1" 189 | if [[ ${SUBSPACE_IPV4_NAT_ENABLED} -ne 0 ]]; then 190 | DNSMASQ_LISTEN_ADDRESS="${DNSMASQ_LISTEN_ADDRESS},${SUBSPACE_IPV4_GW}" 191 | fi 192 | if [[ ${SUBSPACE_IPV6_NAT_ENABLED} -ne 0 ]]; then 193 | DNSMASQ_LISTEN_ADDRESS="${DNSMASQ_LISTEN_ADDRESS},${SUBSPACE_IPV6_GW}" 194 | fi 195 | 196 | if ! test -d /etc/service/dnsmasq; then 197 | cat </etc/dnsmasq.conf 198 | # Only listen on necessary addresses. 199 | listen-address=${DNSMASQ_LISTEN_ADDRESS} 200 | 201 | # Never forward plain names (without a dot or domain part) 202 | domain-needed 203 | 204 | # Never forward addresses in the non-routed address spaces. 205 | bogus-priv 206 | 207 | # Allow extending dnsmasq by providing custom configurations. 208 | conf-dir=/etc/dnsmasq.d 209 | DNSMASQ 210 | 211 | mkdir -p /etc/service/dnsmasq 212 | cat </etc/service/dnsmasq/run 213 | #!/bin/sh 214 | exec /usr/sbin/dnsmasq --keep-in-foreground 215 | RUNIT 216 | chmod +x /etc/service/dnsmasq/run 217 | 218 | # dnsmasq service log 219 | mkdir -p /etc/service/dnsmasq/log/main 220 | cat </etc/service/dnsmasq/log/run 221 | #!/bin/sh 222 | exec svlogd -tt ./main 223 | RUNIT 224 | chmod +x /etc/service/dnsmasq/log/run 225 | fi 226 | fi 227 | 228 | # subspace service 229 | if ! test -d /etc/service/subspace; then 230 | mkdir /etc/service/subspace 231 | cat </etc/service/subspace/run 232 | #!/bin/sh 233 | source /etc/envvars 234 | exec /usr/bin/subspace \ 235 | "--http-host=${SUBSPACE_HTTP_HOST}" \ 236 | "--http-addr=${SUBSPACE_HTTP_ADDR}" \ 237 | "--http-insecure=${SUBSPACE_HTTP_INSECURE}" \ 238 | "--backlink=${SUBSPACE_BACKLINK}" \ 239 | "--letsencrypt=${SUBSPACE_LETSENCRYPT}" \ 240 | "--theme=${SUBSPACE_THEME}" 241 | RUNIT 242 | chmod +x /etc/service/subspace/run 243 | 244 | # subspace service log 245 | mkdir /etc/service/subspace/log 246 | mkdir /etc/service/subspace/log/main 247 | cat </etc/service/subspace/log/run 248 | #!/bin/sh 249 | exec svlogd -tt ./main 250 | RUNIT 251 | chmod +x /etc/service/subspace/log/run 252 | fi 253 | 254 | exec $@ 255 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/subspacecommunity/subspace 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/crewjam/saml v0.4.5 7 | github.com/dustin/go-humanize v1.0.0 8 | github.com/gorilla/securecookie v1.1.1 9 | github.com/jteeuwen/go-bindata v3.0.8-0.20180305030458-6025e8de665b+incompatible 10 | github.com/julienschmidt/httprouter v1.3.0 11 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 12 | github.com/pquerna/otp v1.2.0 // indirect 13 | github.com/sirupsen/logrus v1.6.0 14 | github.com/skip2/go-qrcode v0.0.0-20200519171959-a3b48390827e 15 | golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 16 | golang.org/x/net v0.0.0-20200519113804-d87ec0cfa476 17 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 18 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 19 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beevik/etree v1.0.1 h1:lWzdj5v/Pj1X360EV7bUudox5SRipy4qZLjY0rhb0ck= 2 | github.com/beevik/etree v1.0.1/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= 3 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= 4 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 5 | github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= 6 | github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= 7 | github.com/crewjam/httperr v0.0.0-20190612203328-a946449404da h1:WXnT88cFG2davqSFqvaFfzkSMC0lqh/8/rKZ+z7tYvI= 8 | github.com/crewjam/httperr v0.0.0-20190612203328-a946449404da/go.mod h1:+rmNIXRvYMqLQeR4DHyTvs6y0MEMymTz4vyFpFkKTPs= 9 | github.com/crewjam/saml v0.4.5 h1:H9u+6CZAESUKHxMyxUbVn0IawYvKZn4nt3d4ccV4O/M= 10 | github.com/crewjam/saml v0.4.5/go.mod h1:qCJQpUtZte9R1ZjUBcW8qtCNlinbO363ooNl02S68bk= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= 15 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 16 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 17 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 18 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 19 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 20 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 21 | github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= 22 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 23 | github.com/jonboulle/clockwork v0.2.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= 24 | github.com/jonboulle/clockwork v0.2.1 h1:S/EaQvW6FpWMYAvYvY+OBDvpaM+izu0oiwo5y0MH7U0= 25 | github.com/jonboulle/clockwork v0.2.1/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= 26 | github.com/jteeuwen/go-bindata v3.0.8-0.20180305030458-6025e8de665b+incompatible h1:eX6cWzw+KSwhN430wwbdWPgqnlbnK5ux76/q5ko+Qu8= 27 | github.com/jteeuwen/go-bindata v3.0.8-0.20180305030458-6025e8de665b+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs= 28 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 29 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 30 | github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= 31 | github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 32 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 33 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 34 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 35 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 36 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 37 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 38 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 39 | github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e h1:qqXczln0qwkVGcpQ+sQuPOVntt2FytYarXXxYSNJkgw= 40 | github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= 41 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 42 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 43 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 44 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 45 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 46 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 47 | github.com/pquerna/otp v1.2.0 h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok= 48 | github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= 49 | github.com/russellhaering/goxmldsig v0.0.0-20180430223755-7acd5e4a6ef7 h1:J4AOUcOh/t1XbQcJfkEqhzgvMJ2tDxdCVvmHxW5QXao= 50 | github.com/russellhaering/goxmldsig v0.0.0-20180430223755-7acd5e4a6ef7/go.mod h1:Oz4y6ImuOQZxynhbSXk7btjEfNBtGlj2dcaOvXl2FSM= 51 | github.com/russellhaering/goxmldsig v1.1.0 h1:lK/zeJie2sqG52ZAlPNn1oBBqsIsEKypUUBGpYYF6lk= 52 | github.com/russellhaering/goxmldsig v1.1.0/go.mod h1:QK8GhXPB3+AfuCrfo0oRISa9NfzeCpWmxeGnqEpDF9o= 53 | github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= 54 | github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= 55 | github.com/skip2/go-qrcode v0.0.0-20200519171959-a3b48390827e h1:xVeSA6fTG0og2KsF+Jh9vzx8gYRtBfLmpXzp3L1eThY= 56 | github.com/skip2/go-qrcode v0.0.0-20200519171959-a3b48390827e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 57 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 58 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 59 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 60 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 61 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 62 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 63 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 64 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 65 | github.com/zenazn/goji v0.9.1-0.20160507202103-64eb34159fe5/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= 66 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 67 | golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= 68 | golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw= 69 | golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 70 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 71 | golang.org/x/net v0.0.0-20200519113804-d87ec0cfa476 h1:E7ct1C6/33eOdrGZKMoyntcEvs2dwZnDe30crG5vpYU= 72 | golang.org/x/net v0.0.0-20200519113804-d87ec0cfa476/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 73 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 74 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 75 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= 76 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 77 | golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 78 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= 79 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 80 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 81 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 82 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 83 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 84 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 85 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 86 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 87 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 88 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= 89 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= 90 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 91 | gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= 92 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 93 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 94 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | -------------------------------------------------------------------------------- /scripts/dockerfiles/386.dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS builder 2 | 3 | # Download QEMU, see https://github.com/docker/hub-feedback/issues/1261 4 | ENV QEMU_URL https://github.com/balena-io/qemu/releases/download/v3.0.0%2Bresin/qemu-3.0.0+resin-aarch64.tar.gz 5 | RUN apk add curl && curl -L ${QEMU_URL} | tar zxvf - -C . --strip-components 1 6 | 7 | 8 | FROM i386/golang:1.14.4-buster as build 9 | 10 | # Add QEMU 11 | COPY --from=builder qemu-aarch64-static /usr/bin 12 | 13 | RUN apt-get update \ 14 | && apt-get install -y git make \ 15 | && rm -rf /var/lib/apt/lists/* 16 | 17 | WORKDIR /src 18 | 19 | COPY Makefile ./ 20 | # go.mod and go.sum if exists 21 | COPY go.* ./ 22 | COPY cmd/ ./cmd 23 | COPY web ./web 24 | 25 | ARG BUILD_VERSION=unknown 26 | ARG GOARCH=386 27 | 28 | ENV GODEBUG="netdns=go http2server=0" 29 | 30 | RUN make build BUILD_VERSION=${BUILD_VERSION} 31 | 32 | FROM i386/alpine:3.11.6 33 | LABEL maintainer="github.com/subspacecommunity/subspace" 34 | 35 | # Add QEMU 36 | COPY --from=builder qemu-aarch64-static /usr/bin 37 | 38 | ENV DEBIAN_FRONTEND noninteractive 39 | RUN apk add --no-cache \ 40 | iproute2 \ 41 | iptables \ 42 | ip6tables \ 43 | dnsmasq \ 44 | socat \ 45 | wireguard-tools \ 46 | runit 47 | 48 | COPY --from=build /src/subspace /usr/bin/subspace 49 | COPY entrypoint.sh /usr/local/bin/entrypoint.sh 50 | COPY bin/my_init /sbin/my_init 51 | 52 | RUN chmod +x /usr/bin/subspace /usr/local/bin/entrypoint.sh /sbin/my_init 53 | 54 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh" ] 55 | 56 | CMD [ "/sbin/my_init" ] 57 | -------------------------------------------------------------------------------- /scripts/dockerfiles/amd64.dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14 as build 2 | 3 | RUN apt-get update \ 4 | && apt-get install -y git make \ 5 | && rm -rf /var/lib/apt/lists/* 6 | 7 | WORKDIR /src 8 | 9 | COPY Makefile ./ 10 | # go.mod and go.sum if exists 11 | COPY go.* ./ 12 | COPY cmd/ ./cmd 13 | COPY web ./web 14 | 15 | ARG BUILD_VERSION=unknown 16 | ARG GOARCH=amd64 17 | 18 | ENV GODEBUG="netdns=go http2server=0" 19 | 20 | RUN make build BUILD_VERSION=${BUILD_VERSION} 21 | 22 | FROM alpine:3.11.6 23 | LABEL maintainer="github.com/subspacecommunity/subspace" 24 | 25 | ENV DEBIAN_FRONTEND noninteractive 26 | RUN apk add --no-cache \ 27 | iproute2 \ 28 | iptables \ 29 | ip6tables \ 30 | dnsmasq \ 31 | socat \ 32 | wireguard-tools \ 33 | runit 34 | 35 | COPY --from=build /src/subspace /usr/bin/subspace 36 | COPY entrypoint.sh /usr/local/bin/entrypoint.sh 37 | COPY bin/my_init /sbin/my_init 38 | 39 | RUN chmod +x /usr/bin/subspace /usr/local/bin/entrypoint.sh /sbin/my_init 40 | 41 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh" ] 42 | 43 | CMD [ "/sbin/my_init" ] 44 | -------------------------------------------------------------------------------- /scripts/dockerfiles/arm32v5.dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS builder 2 | 3 | # Download QEMU, see https://github.com/docker/hub-feedback/issues/1261 4 | ENV QEMU_URL https://github.com/balena-io/qemu/releases/download/v3.0.0%2Bresin/qemu-3.0.0+resin-arm.tar.gz 5 | RUN apk add curl && curl -L ${QEMU_URL} | tar zxvf - -C . --strip-components 1 6 | 7 | 8 | FROM arm32v5/golang:1.14.4-buster as build 9 | 10 | # Add QEMU 11 | COPY --from=builder qemu-arm-static /usr/bin 12 | 13 | RUN apt-get update \ 14 | && apt-get install -y git make \ 15 | && rm -rf /var/lib/apt/lists/* 16 | 17 | WORKDIR /src 18 | 19 | COPY Makefile ./ 20 | # go.mod and go.sum if exists 21 | COPY go.* ./ 22 | COPY cmd/ ./cmd 23 | COPY web ./web 24 | 25 | ARG BUILD_VERSION=unknown 26 | ARG GOARCH=arm 27 | ENV GOARM=5 28 | 29 | ENV GODEBUG="netdns=go http2server=0" 30 | 31 | RUN make build BUILD_VERSION=${BUILD_VERSION} 32 | 33 | 34 | FROM arm32v5/debian:buster-backports 35 | LABEL maintainer="github.com/subspacecommunity/subspace" 36 | 37 | # Add QEMU 38 | COPY --from=builder qemu-arm-static /usr/bin 39 | 40 | RUN apt-get update \ 41 | && apt-get install -y \ 42 | iproute2 \ 43 | iptables \ 44 | dnsmasq \ 45 | socat \ 46 | wireguard-tools \ 47 | runit \ 48 | && rm -rf /var/lib/apt/lists/* 49 | 50 | COPY --from=build /src/subspace /usr/bin/subspace 51 | COPY entrypoint.sh /usr/local/bin/entrypoint.sh 52 | COPY bin/my_init /sbin/my_init 53 | 54 | ENV DEBIAN_FRONTEND noninteractive 55 | 56 | RUN chmod +x /usr/bin/subspace /usr/local/bin/entrypoint.sh /sbin/my_init 57 | 58 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh" ] 59 | 60 | CMD [ "/sbin/my_init" ] 61 | -------------------------------------------------------------------------------- /scripts/dockerfiles/arm32v6.dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS builder 2 | 3 | # Download QEMU, see https://github.com/docker/hub-feedback/issues/1261 4 | ENV QEMU_URL https://github.com/balena-io/qemu/releases/download/v3.0.0%2Bresin/qemu-3.0.0+resin-arm.tar.gz 5 | RUN apk add curl && curl -L ${QEMU_URL} | tar zxvf - -C . --strip-components 1 6 | 7 | 8 | FROM arm32v6/golang:1.14.4-alpine as build 9 | 10 | # Add QEMU 11 | COPY --from=builder qemu-arm-static /usr/bin 12 | 13 | RUN apk add --no-cache git make gcc musl-dev 14 | 15 | WORKDIR /src 16 | 17 | COPY Makefile ./ 18 | # go.mod and go.sum if exists 19 | COPY go.* ./ 20 | COPY cmd/ ./cmd 21 | COPY web ./web 22 | 23 | ARG BUILD_VERSION=unknown 24 | ARG GOARCH=arm 25 | ENV GOARM=6 26 | 27 | ENV GODEBUG="netdns=go http2server=0" 28 | 29 | RUN make build BUILD_VERSION=${BUILD_VERSION} 30 | 31 | 32 | FROM arm32v6/alpine:3.11.6 33 | LABEL maintainer="github.com/subspacecommunity/subspace" 34 | 35 | # Add QEMU 36 | COPY --from=builder qemu-arm-static /usr/bin 37 | 38 | ENV DEBIAN_FRONTEND noninteractive 39 | RUN apk add --no-cache \ 40 | iproute2 \ 41 | iptables \ 42 | ip6tables \ 43 | dnsmasq \ 44 | socat \ 45 | wireguard-tools \ 46 | runit 47 | 48 | COPY --from=build /src/subspace /usr/bin/subspace 49 | COPY entrypoint.sh /usr/local/bin/entrypoint.sh 50 | COPY bin/my_init /sbin/my_init 51 | 52 | RUN chmod +x /usr/bin/subspace /usr/local/bin/entrypoint.sh /sbin/my_init 53 | 54 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh" ] 55 | 56 | CMD [ "/sbin/my_init" ] 57 | -------------------------------------------------------------------------------- /scripts/dockerfiles/arm32v7.dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS builder 2 | 3 | # Download QEMU, see https://github.com/docker/hub-feedback/issues/1261 4 | ENV QEMU_URL https://github.com/balena-io/qemu/releases/download/v3.0.0%2Bresin/qemu-3.0.0+resin-arm.tar.gz 5 | RUN apk add curl && curl -L ${QEMU_URL} | tar zxvf - -C . --strip-components 1 6 | 7 | 8 | FROM arm32v7/golang:1.14.4-buster as build 9 | 10 | # Add QEMU 11 | COPY --from=builder qemu-arm-static /usr/bin 12 | 13 | RUN apt-get update \ 14 | && apt-get install -y git make \ 15 | && rm -rf /var/lib/apt/lists/* 16 | 17 | WORKDIR /src 18 | 19 | COPY Makefile ./ 20 | # go.mod and go.sum if exists 21 | COPY go.* ./ 22 | COPY cmd/ ./cmd 23 | COPY web ./web 24 | 25 | ARG BUILD_VERSION=unknown 26 | ARG GOARCH=arm 27 | ENV GOARM=7 28 | 29 | ENV GODEBUG="netdns=go http2server=0" 30 | 31 | RUN make build BUILD_VERSION=${BUILD_VERSION} 32 | 33 | FROM arm32v7/alpine:3.11.6 34 | LABEL maintainer="github.com/subspacecommunity/subspace" 35 | 36 | # Add QEMU 37 | COPY --from=builder qemu-arm-static /usr/bin 38 | 39 | ENV DEBIAN_FRONTEND noninteractive 40 | RUN apk add --no-cache \ 41 | iproute2 \ 42 | iptables \ 43 | ip6tables \ 44 | dnsmasq \ 45 | socat \ 46 | wireguard-tools \ 47 | runit 48 | 49 | COPY --from=build /src/subspace /usr/bin/subspace 50 | COPY entrypoint.sh /usr/local/bin/entrypoint.sh 51 | COPY bin/my_init /sbin/my_init 52 | 53 | RUN chmod +x /usr/bin/subspace /usr/local/bin/entrypoint.sh /sbin/my_init 54 | 55 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh" ] 56 | 57 | CMD [ "/sbin/my_init" ] 58 | -------------------------------------------------------------------------------- /scripts/dockerfiles/arm64v8.dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS builder 2 | 3 | # Download QEMU, see https://github.com/docker/hub-feedback/issues/1261 4 | ENV QEMU_URL https://github.com/balena-io/qemu/releases/download/v3.0.0%2Bresin/qemu-3.0.0+resin-aarch64.tar.gz 5 | RUN apk add curl && curl -L ${QEMU_URL} | tar zxvf - -C . --strip-components 1 6 | 7 | 8 | FROM arm64v8/golang:1.14.4-buster as build 9 | 10 | # Add QEMU 11 | COPY --from=builder qemu-aarch64-static /usr/bin 12 | 13 | RUN apt-get update \ 14 | && apt-get install -y git make \ 15 | && rm -rf /var/lib/apt/lists/* 16 | 17 | WORKDIR /src 18 | 19 | COPY Makefile ./ 20 | # go.mod and go.sum if exists 21 | COPY go.* ./ 22 | COPY cmd/ ./cmd 23 | COPY web ./web 24 | 25 | ARG BUILD_VERSION=unknown 26 | ARG GOARCH=arm64 27 | 28 | ENV GODEBUG="netdns=go http2server=0" 29 | 30 | RUN make build BUILD_VERSION=${BUILD_VERSION} 31 | 32 | FROM arm64v8/alpine:3.11.6 33 | LABEL maintainer="github.com/subspacecommunity/subspace" 34 | 35 | # Add QEMU 36 | COPY --from=builder qemu-aarch64-static /usr/bin 37 | 38 | ENV DEBIAN_FRONTEND noninteractive 39 | RUN apk add --no-cache \ 40 | iproute2 \ 41 | iptables \ 42 | ip6tables \ 43 | dnsmasq \ 44 | socat \ 45 | wireguard-tools \ 46 | runit 47 | 48 | COPY --from=build /src/subspace /usr/bin/subspace 49 | COPY entrypoint.sh /usr/local/bin/entrypoint.sh 50 | COPY bin/my_init /sbin/my_init 51 | 52 | RUN chmod +x /usr/bin/subspace /usr/local/bin/entrypoint.sh /sbin/my_init 53 | 54 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh" ] 55 | 56 | CMD [ "/sbin/my_init" ] 57 | -------------------------------------------------------------------------------- /scripts/dockerfiles/hooks/post_push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Use manifest-tool to create the manifest, given the experimental 4 | # "docker manifest" command isn't available yet on Docker Hub. 5 | 6 | curl -Lo manifest-tool https://github.com/estesp/manifest-tool/releases/download/v0.9.0/manifest-tool-linux-amd64 7 | chmod +x manifest-tool 8 | 9 | git_tag=$(git describe --abbrev=0 --tags) 10 | IFS=. read major minor bugfix < multi-arch-manifest.yaml 15 | image: subspacecommunity/subspace 16 | tags: ['latest', '${major}.${minor}.${bugfix}', '${major}.${minor}', '${major}'] 17 | manifests: 18 | - image: subspacecommunity/subspace:amd64 19 | platform: 20 | architecture: amd64 21 | os: linux 22 | - image: subspacecommunity/subspace:386 23 | platform: 24 | architecture: 386 25 | os: linux 26 | - image: subspacecommunity/subspace:arm32v6 27 | platform: 28 | architecture: arm 29 | os: linux 30 | variant: v6 31 | - image: subspacecommunity/subspace:arm32v7 32 | platform: 33 | architecture: arm 34 | os: linux 35 | variant: v7 36 | - image: subspacecommunity/subspace:arm64v8 37 | platform: 38 | architecture: arm64 39 | os: linux 40 | variant: v8 41 | EOF 42 | 43 | ./manifest-tool push from-spec multi-arch-manifest.yaml 44 | -------------------------------------------------------------------------------- /scripts/dockerfiles/hooks/pre_build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Register qemu-*-static for all supported processors except the 4 | # current one, but also remove all registered binfmt_misc before 5 | docker run --rm --privileged multiarch/qemu-user-static:register --reset 6 | -------------------------------------------------------------------------------- /web/email/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /web/email/forgot.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 |

4 | Password Reset Link 5 |

6 | 7 | 10 | 11 |

12 | NOTE: If you did not request a password reset, simply ignore this email. 13 |

14 | 15 | {{template "footer.html" .}} 16 | -------------------------------------------------------------------------------- /web/email/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Subspace 7 | 76 | 77 | 78 | 79 |
80 | -------------------------------------------------------------------------------- /web/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/favicon.png -------------------------------------------------------------------------------- /web/static/fonts/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-Black.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-BlackItalic.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-Bold.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-BoldItalic.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-Italic.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-Light.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-LightItalic.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-MediumItalic.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-Thin.ttf -------------------------------------------------------------------------------- /web/static/fonts/Roboto-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/fonts/Roboto-ThinItalic.ttf -------------------------------------------------------------------------------- /web/static/roboto.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Roboto'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: local('Roboto'), local('Roboto-Light'), url(fonts/Roboto-Light.ttf) format('truetype'); 6 | } 7 | -------------------------------------------------------------------------------- /web/static/script.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/script.js -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/brand-icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/brand-icons.eot -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/brand-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/brand-icons.ttf -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/brand-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/brand-icons.woff -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/brand-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/brand-icons.woff2 -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/icons.eot -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/icons.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/icons.otf -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/icons.ttf -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/icons.woff -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/icons.woff2 -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/outline-icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/outline-icons.eot -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/outline-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/outline-icons.ttf -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/outline-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/outline-icons.woff -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/fonts/outline-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/fonts/outline-icons.woff2 -------------------------------------------------------------------------------- /web/static/semantic/themes/default/assets/images/flags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subspacecommunity/subspace/1a2d4f2b1801b1d120a0b99b72684b460fdd4b37/web/static/semantic/themes/default/assets/images/flags.png -------------------------------------------------------------------------------- /web/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-bottom: 100px; 3 | color: #222; 4 | background-color: #fdfdfd; 5 | } 6 | 7 | .showcard { 8 | background-color: #fdfdfd !important; 9 | } 10 | 11 | .block.header { 12 | background-color: #fdfdfd !important; 13 | } 14 | 15 | .left-aligned { 16 | text-align: left; 17 | } 18 | 19 | .right-aligned { 20 | text-align: right; 21 | } 22 | 23 | .center-aligned { 24 | text-align: center; 25 | } 26 | 27 | .close-link { 28 | color: #000 !important; 29 | position: absolute; 30 | right: 5px; 31 | top: 5px; 32 | } 33 | 34 | .copylink { 35 | color: yellow !important; 36 | background-color: #444 !important; 37 | } 38 | .copylink::selection { 39 | color: #fff !important; 40 | } 41 | 42 | .break-word { 43 | overflow-wrap: break-word !important; 44 | } 45 | 46 | .borderless.menu { 47 | border-radius: 0 !important; 48 | } 49 | 50 | .navmenu img { 51 | max-width: 22px; 52 | } 53 | 54 | .centered-column { 55 | max-width: 350px !important; 56 | } 57 | 58 | .page-text { 59 | margin-top: 0.5em; 60 | font-size: 1.4em; 61 | line-height: 1.6em; 62 | color: #555; 63 | overflow-wrap: break-word; 64 | } 65 | 66 | .readonly-input { 67 | background-color: #f1f1f1 !important; 68 | } 69 | 70 | p { 71 | font-size: 1.33em !important; 72 | } 73 | -------------------------------------------------------------------------------- /web/templates/configure.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 | 4 | 5 |
6 |
7 | 8 |
Setup Admin Account
9 | 10 | 11 | {{with $error := .Request.FormValue "error"}} 12 |
13 |
14 |
15 | {{if eq $error "invalid"}} 16 | Invalid. Please try again. 17 | {{else}} 18 | Error. Please try again. 19 | {{end}} 20 |
21 |
22 |
23 | 24 | {{end}} 25 | 26 |
27 |
28 |
29 | Welcome to your new Subspace instance! 30 |
31 | Please complete the setup of your admin account below. 32 |
33 |
34 | 35 | 36 | 37 |
38 |
39 |
Email Address
40 | 41 |
42 |
43 |
Confirm Email
44 | 45 |
46 |
47 |
Create Password
48 | 49 |
50 |
51 | 52 | 53 |
54 | 55 |
56 |
57 |
58 | 59 |
60 |
61 | 62 | {{template "footer.html" .}} 63 | -------------------------------------------------------------------------------- /web/templates/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{if eq $.Section "index"}} 7 |
8 |
9 |
10 | 11 |
12 |
13 |
© Copyright — All Rights Reserved
14 |
15 | 16 | 17 | 18 | 21 |
22 | 23 |
24 |
25 |
26 | {{end}} 27 | 28 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /web/templates/forgot.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 | 4 | 5 |
6 |
7 |
8 |
9 | 10 |
Reset Admin Password
11 | 12 | {{with $error := $.Request.FormValue "error"}} 13 |
14 |
15 |
16 | {{if eq $error "invalid"}} 17 | Reset password failed. Please try again. 18 | {{else}} 19 | Error. Please try again. 20 | {{end}} 21 |
22 |
23 |
24 | 25 | {{end}} 26 | {{with $success := $.Request.FormValue "success"}} 27 |
28 |
29 |
30 | {{if eq $success "forgot"}} 31 | An email with your password reset link has been sent. Check your spam folder. 32 | {{else}} 33 | Success 34 | {{end}} 35 |
36 |
37 |
38 | 39 | {{end}} 40 | 41 | {{$email := $.Request.FormValue "email"}} 42 | {{$secret := $.Request.FormValue "secret"}} 43 |
44 | {{if and $email $secret}} 45 | 46 | 47 |
48 |
49 | 50 | 51 |
52 |
53 |
54 | 55 | {{$.TargetUser.ID}} 56 |
57 | {{else}} 58 |
59 |
60 | 61 | 62 |
63 |
64 |
65 | 66 |
67 | {{end}} 68 | 69 |
70 |
71 |
72 |
73 |
74 | 75 | {{template "footer.html" .}} 76 | -------------------------------------------------------------------------------- /web/templates/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Subspace 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 50 | 51 | -------------------------------------------------------------------------------- /web/templates/help.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 |
4 |
5 | 6 |
7 | Subspace 8 |
9 | Private WireGuard® VPN Server 10 |
11 |
12 | 13 | 14 | 15 |
16 |
17 | 18 |
19 |
20 | Encrypt your connection 21 |
22 |
23 | Securely access the internet, even on untrusted wifi connections 24 |
25 |
26 |
27 |
28 | 29 |
30 |
31 | Stop your internet provider from spying on you 32 |
33 |
34 | Maintain your privacy rights by encrypting your home traffic 35 |
36 |
37 |
38 |
39 | 40 |
41 |
42 | Connect from any device 43 |
44 |
45 | Use any VPN app that supports WireGuard, on desktop or mobile 46 |
47 |
48 |
49 |
50 | 51 | 52 | 53 |
License
54 |

55 | MIT License 56 |

57 | 58 |

59 | Permission is hereby granted, free of charge, to any person obtaining a copy 60 | of this software and associated documentation files (the "Software"), to deal 61 | in the Software without restriction, including without limitation the rights 62 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 63 | copies of the Software, and to permit persons to whom the Software is 64 | furnished to do so, subject to the following conditions: 65 |

66 | 67 |

68 | The above copyright notice and this permission notice shall be included in all 69 | copies or substantial portions of the Software. 70 |

71 | 72 |

73 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 74 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 75 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 76 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 77 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 78 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 79 | SOFTWARE. 80 |

81 | 82 | 83 | 84 |
Source Code
85 | 86 |

87 | Subspace on GitHub 88 |

89 | 90 | 91 | 92 |
Trademarks
93 |

94 | WireGuard is a registered trademark of Jason A. Donenfeld 95 |

96 |
97 |
98 | 99 | {{template "footer.html" .}} 100 | 101 | -------------------------------------------------------------------------------- /web/templates/index.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 |
4 | 5 | {{with $success := $.Request.FormValue "success"}} 6 |
7 |
8 | {{if eq $success "settings"}} 9 | Settings saved successfully 10 | {{else if eq $success "deleteprofile"}} 11 | Device deleted successfully 12 | {{else if eq $success "deleteuser"}} 13 | User deleted successfully 14 | {{end}} 15 |
16 | 17 |
18 | {{end}} 19 | {{with $error := $.Request.FormValue "error"}} 20 |
21 |
22 |
23 | {{if eq $error "addprofile"}} 24 | Adding device failed 25 | {{else if eq $error "deleteprofile"}} 26 | Adding device failed 27 | {{else if eq $error "profilename"}} 28 | Device name is required 29 | {{else}} 30 | {{$error}} 31 | {{end}} 32 |
33 | 34 |
35 |
36 | 37 | {{end}} 38 | 39 |
40 |
41 |
42 | 43 |
44 | 45 |
Subspace is online
46 |
47 | 48 |
49 |
50 |
51 |
52 | 53 |
54 | 55 | {{if $.User.Admin}} 56 |
57 |
58 | 67 |
68 | {{end}} 69 |
70 | 83 |
84 | {{if $.User.Admin}} 85 |
86 | {{end}} 87 |
88 | 89 |
90 |
91 |
92 |
93 |
94 | 95 | {{if $.User.ID}} 96 | 97 |
98 |
99 | Your devices 100 |
101 | 102 | 103 | {{if $.TargetProfiles}} 104 |
105 | {{range $n, $p := $.TargetProfiles}} 106 |
107 | 108 |
109 |
110 | {{$p.Name}} 125 |
126 |
127 |
128 |
129 |
130 | 131 |
132 |
133 | Created 134 |
135 |
136 | {{time $p.Created}} 137 |
138 |
139 |
140 |
141 |
142 |
143 | Connect device 144 |
145 |
146 | {{end}} 147 |
148 | {{else}} 149 |
There are currently no devices
150 | {{end}} 151 |
152 | {{end}} 153 | 154 | 155 | {{if $.Admin}} 156 | 157 |
158 |
159 | Admin devices 160 |
161 | 162 | 163 | {{if $.Profiles}} 164 |
165 | {{range $n, $p := $.Profiles}} 166 |
167 | 168 |
169 |
170 | {{$p.Name}} 185 |
186 |
187 |
188 |
189 |
190 | 191 |
192 |
193 | Created 194 |
195 |
196 | {{time $p.Created}} 197 |
198 |
199 |
200 |
201 |
202 |
203 | Connect device 204 |
205 |
206 | {{end}} 207 |
208 | {{else}} 209 |
There are currently no devices
210 | {{end}} 211 |
212 | {{end}} 213 | 214 | {{if $.Users}} 215 | 216 |
217 |
218 | Single sign-on users 219 |
220 | 221 | 222 |
223 | {{range $n, $u := $.Users}} 224 |
225 | 226 |
227 |
228 | 229 | {{$u.Email}} 230 |
231 |
232 |
233 |
234 |
235 | 236 |
237 |
238 | Devices 239 |
240 |
241 | {{len $u.Profiles}} devices 242 |
243 |
244 |
245 |
246 | 247 |
248 |
249 | Created 250 |
251 |
252 | {{time $u.Created}} 253 |
254 |
255 |
256 |
257 |
258 |
259 | Manage user 260 |
261 |
262 | {{end}} 263 |
264 |
265 | {{end}} 266 | 267 |
268 | 269 | {{template "footer.html" .}} 270 | -------------------------------------------------------------------------------- /web/templates/profile/connect.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 |
4 | Back 5 | 6 | 7 | {{with $success := $.Request.FormValue "success"}} 8 |
9 |
10 |
11 | {{if eq $success "addprofile"}} 12 | Device added successfully 13 | {{else}} 14 | {{$success}} 15 | {{end}} 16 |
17 | 18 |
19 |
20 | 21 | {{end}} 22 | 23 |
24 |
Connecting {{$.Profile.Name}}
25 | 26 | 27 | {{if eq $.Profile.Platform "windows"}} 28 |
Windows — Instructions
29 |
30 |
31 | 32 |
33 |
34 | Download and install the WireGuard client for Windows 35 |
36 |
37 |
38 |
39 | 40 |
41 |
42 | Download your WireGuard config and import it into WireGuard 43 |
44 |
45 |
46 |
47 | 48 |
49 |
50 | Active your VPN 51 |
52 |
53 |
54 |
55 | {{else if eq $.Profile.Platform "osx"}} 56 |
macOS — Instructions
57 |
58 |
59 | 60 |
61 |
62 | Download your WireGuard config into your Downloads folder 63 |
64 |
65 |
66 |
67 | 68 |
69 |
70 | Install the WireGuard app from the Mac App Store 71 |
72 |
73 |
74 |
75 | 76 |
77 |
78 | Launch WireGuard, and click on the WireGuard icon in the menu bar 79 |
80 |
81 |
82 |
83 | 84 |
85 |
86 | Click on the WireGuard icon and select "Import tunnel(s) from file" 87 |
88 |
89 |
90 |
91 | 92 |
93 |
94 | Import your WireGuard config file from your Downloads folder 95 |
96 |
97 |
98 |
99 | 100 |
101 |
102 | Activate your WireGuard tunnel 103 |
104 |
105 |
106 |
107 | {{else if eq $.Profile.Platform "ios"}} 108 |
iOS (iPhone/iPad) — Instructions
109 |
110 |
111 | 112 |
113 |
114 | Install WireGuard from the App Store 115 |
116 |
117 |
118 |
119 | 120 |
121 |
122 | Download your WireGuard config and open with WireGuard or use the QR code below 123 |
124 |
125 |
126 |
127 | 128 |
129 |
130 | Go to your iOS Settings 131 |
132 |
133 |
134 |
135 | 136 |
137 |
138 | Turn your VPN on 139 |
140 |
141 |
142 |
143 | 144 | 145 | qr-code could not be displayed 146 | 147 | {{else if eq $.Profile.Platform "android"}} 148 |
Android — Instructions
149 |
150 |
151 | 152 |
153 |
154 | Install WireGuard for Android 155 |
156 |
157 |
158 |
159 | 160 |
161 |
162 | Download your WireGuard config or use the QR code below 163 |
164 |
165 |
166 |
167 | 168 |
169 |
170 | Connect to your VPN server 171 |
172 |
173 |
174 |
175 | 176 | 177 | qr-code could not be displayed 178 | 179 | {{else if eq $.Profile.Platform "linux"}} 180 |
Linux — Instructions
181 |
182 |
183 | 184 |
185 |
186 | Install WireGuard 187 |
188 |
189 |
190 |
191 | 192 |
193 | 196 |
197 |
198 |
199 | 200 |
201 |
202 | Connect to your VPN server 203 |
204 |
205 |
206 |
207 | {{else}} 208 |
WireGuard
209 |
210 |
211 | 212 |
213 |
214 | Install WireGuard 215 |
216 |
217 |
218 |
219 | 220 |
221 | 224 |
225 |
226 |
227 | 228 |
229 |
230 | Connect to your VPN server 231 |
232 |
233 |
234 |
235 | 236 | {{end}} 237 | 238 | 239 |
240 |
241 | 242 | {{template "footer.html" .}} 243 | -------------------------------------------------------------------------------- /web/templates/profile/delete.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 |
4 |
5 |
6 |
7 |
8 | Delete device 9 |
10 |
11 | {{$.Profile.Name}} 12 |
13 | 14 | 15 |
16 | 17 |
18 | Cancel 19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 | {{template "footer.html" .}} 28 | -------------------------------------------------------------------------------- /web/templates/settings.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 | 4 | 5 |
6 |
7 | {{with $success := $.Request.FormValue "success"}} 8 |
9 |
10 | {{if eq $success "settings"}} 11 | Settings saved successfully 12 | {{else if eq $success "removeprofile"}} 13 | Device removed successfully 14 | {{else if eq $success "configured"}} 15 | Admin account is setup. Configure SAML for SSO (optional). 16 | {{else if eq $success "totp"}} 17 | TOTP reset for default user, please reconfigure for improved security. 18 | {{end}} 19 |
20 | 21 |
22 | 23 | {{end}} 24 | 25 | {{with $error := .Request.FormValue "error"}} 26 |
27 |
28 |
29 | {{if eq $error "invalid"}} 30 | Invalid. Please try again. 31 | {{else if eq $error "totp"}} 32 | Error Resetting totp settings. 33 | {{else}} 34 | Error. Please try again. 35 | {{end}} 36 |
37 |
38 |
39 | 40 | {{end}} 41 | 42 |
43 |
Single Sign-On (SAML)
44 |
45 | 46 | To enable Single Sign-On using SAML add a custom SAML app in your identity provider's admin panel, then copy/paste the IDP metadata XML below. 47 | 53 |
54 | 55 | 56 |
57 |
Your ACS URL
58 | 59 |
60 |
61 |
Your Entity ID
62 | 63 |
64 |
65 |
IDP Metadata
66 | 67 |
68 | 69 |
70 | 71 |
72 |
73 | Cancel 74 | 75 |
76 |
77 |
78 | 79 | 80 | 81 |
Admin Account: Reset Password
82 | 83 |
84 |
Email Address
85 | 86 |
87 |
88 |
Current Password
89 | 90 |
91 |
92 |
New Password
93 | 94 |
95 | 96 |
97 | 98 |
99 |
100 | Cancel 101 | 102 |
103 |
104 |
105 | 106 | 107 | {{if and $.Admin $.Info.TotpKey}} 108 | 109 |
Admin Account: Reset TOTP
110 | 111 | 112 |
113 | 114 |
115 |
116 | Cancel 117 | 118 |
119 |
120 |
121 | {{else}} 122 |
Admin Account: Setup MFA
123 | 124 |
Scan the below with your Authenticator App of choice (Google Authenticator, Authy etc...) and then put the code into the input box below
125 | 126 |
127 |
Secret: {{$.TempTotpKey.Secret}}
128 | TOTP qr-code could not be displayed 129 |
130 | 131 |
132 |
TOTP Code
133 | 134 |
135 | 136 |
137 | 138 |
139 |
140 | Cancel 141 | 142 |
143 |
144 |
145 | {{end}} 146 |
147 |
148 |
149 | 150 | {{template "footer.html" .}} 151 | -------------------------------------------------------------------------------- /web/templates/signin.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 |
4 |
5 |
6 |
7 | 8 | {{with $error := $.Request.FormValue "error"}} 9 |
10 |
11 |
12 | {{if eq $error "invalid"}} 13 | Sign in failed. Please try again. 14 | {{else}} 15 | {{$error}} 16 | {{end}} 17 |
18 |
19 |
20 | 21 | {{end}} 22 | 23 | {{if $.SAML}} 24 |
Single Sign-On (SSO)
25 | 26 | 27 | {{$ssoprovider := ssoprovider}} 28 | {{if eq $ssoprovider "Google"}}{{end}}Sign in with {{$ssoprovider}} 29 | 30 | 31 | 32 | {{end}} 33 | 34 |
Admin Sign In
35 | 36 |
37 |
38 |
39 | 40 | 41 |
42 |
43 |
44 |
45 | 46 | 47 |
48 |
49 | 50 | {{ if $.Info.TotpKey}} 51 |
52 |
53 | 54 | 55 |
56 |
57 | {{end}} 58 | 59 |
60 | 61 | 62 | Forgot password? 63 |
64 | 65 |
66 | 67 |
68 |
69 |
70 | 71 | {{template "footer.html" .}} 72 | -------------------------------------------------------------------------------- /web/templates/user/delete.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 |
4 |
5 |
6 |
7 |
8 | Delete user & devices 9 |
10 |
11 | {{$.TargetUser.Email}} 12 |
13 | 14 | 15 |
16 | 17 |
18 | Cancel 19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 | {{template "footer.html" .}} 28 | -------------------------------------------------------------------------------- /web/templates/user/edit.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 |
4 | Back 5 | 6 | 7 |
8 |
9 | {{$.TargetUser.Email}} 10 |
11 | 12 | 13 | {{if $.Profiles}} 14 |
15 | {{range $n, $p := $.Profiles}} 16 |
17 | 18 |
19 |
20 | {{$p.Name}} 35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 | Created 44 |
45 |
46 | {{time $p.Created}} 47 |
48 |
49 |
50 |
51 |
52 |
53 | Connect device 54 |
55 |
56 | {{end}} 57 |
58 | {{else}} 59 |
There are currently no devices
60 | {{end}} 61 | 62 | 63 | 64 |
65 | 66 | 67 |
68 |
69 |
70 | 71 |
72 |
73 | {{if $.TargetUser.Admin}} 74 | 75 | {{else}} 76 | 77 | {{end}} 78 |
79 |
80 | Delete 81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | 89 | {{template "footer.html" .}} 90 | 91 | --------------------------------------------------------------------------------