Initial commit

This commit is contained in:
paul 2018-04-22 16:55:50 +02:00
commit 2422e3108f
37 changed files with 12691 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
*_vfsdata.go
certman
db.sqlite3
*.crt
*.key
clients.json
.env

90
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,90 @@
image: golang:latest
variables:
REPO_NAME: git.klink.asia/paul/certman
# The problem is that to be able to use go get, one needs to put
# the repository in the $GOPATH. So for example if your gitlab domain
# is gitlab.com, and that your repository is namespace/project, and
# the default GOPATH being /go, then you'd need to have your
# repository in /go/src/gitlab.com/namespace/project
# Thus, making a symbolic link corrects this.
before_script:
- mkdir -p $GOPATH/src/$REPO_NAME
- ln -svf $CI_PROJECT_DIR/* $GOPATH/src/$REPO_NAME
- cd $GOPATH/src/$REPO_NAME
stages:
- test
- build
- release
format:
stage: test
tags:
- docker
script:
# we use tags="dev" so there is no dependency on the prebuilt assets yet
- go get -tags="dev" -v $(go list ./... | grep -v /vendor/) # get missing dependencies
- go fmt $(go list ./... | grep -v /vendor/)
- go vet $(go list ./... | grep -v /vendor/)
- go test -tags="dev" -race $(go list ./... | grep -v /vendor/) -v -coverprofile .testCoverage.txt
# Use coverage parsing regex: ^coverage:\s(\d+(?:\.\d+)?%)
compile:
stage: build
tags:
- docker
script:
# we use tags="dev" so there is no dependency on the prebuilt assets yet
- go get -tags="dev" -v $(go list ./... | grep -v /vendor/) # get missing dependencies
# generate assets
- go get github.com/shurcooL/vfsgen/cmd/vfsgendev
- go generate git.klink.asia/paul/certman/assets
# build binaries -- list of supported plattforms is here:
# https://stackoverflow.com/a/20728862
- GOOS=linux GOARCH=amd64 go build -tags "netgo" -o $CI_PROJECT_DIR/certman
- GOOS=linux GOARCH=arm GOARM=6 go build -tags "netgo" -o $CI_PROJECT_DIR/certman.arm
- GOOS=windows GOARCH=amd64 go build -tags "netgo" -o $CI_PROJECT_DIR/certman.exe
artifacts:
expire_in: "8 hrs"
paths:
- certman
- certman.arm
- certman.exe
minify:
stage: release
tags:
- docker
dependencies:
- compile
image:
name: znly/upx:latest
entrypoint: ["/bin/sh", "-c"]
script:
- upx --best --brute $CI_PROJECT_DIR/certman $CI_PROJECT_DIR/certman.arm $CI_PROJECT_DIR/certman.exe
artifacts:
paths:
- certman
- certman.arm
- certman.exe
only:
- tags
build_image:
stage: release
tags:
- dind
image: "docker:latest"
services:
- docker:dind
script:
- cd $CI_PROJECT_DIR
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:${CI_COMMIT_REF_NAME#v} .
- docker push $CI_REGISTRY_IMAGE:${CI_COMMIT_REF_NAME#v}
# only:
# - tags

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM golang:1.9
WORKDIR /go/src/git.klink.asia/paul/certman
ADD . .
RUN \
go get -tags="dev" -v git.klink.asia/paul/certman && \
go get github.com/shurcooL/vfsgen/cmd/vfsgendev && \
go generate git.klink.asia/paul/certman/assets && \
go build -tags="netgo"
FROM scratch
ENV \
APP_KEY="" \
OAUTH2_CLIENT_ID="" \
OAUTH2_CLIENT_SECRET="" \
OAUTH2_AUTH_URL="https://gitlab.example.com/oauth/authorize" \
OAUTH2_TOKEN_URL="https://gitlab.example.com/oauth/token" \
OAUTH2_REDIRECT_URL="https://certman.example.com/login/oauth2/redirect" \
USER_ENDPOINT="https://gitlab.example.com/api/v4/user" \
APP_KEY=""
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=0 /go/src/git.klink.asia/paul/certman/certman /
ENTRYPOINT ["/certman"]

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# Certman
Certman is a simple certificate manager web service for OpenVPN.
## Installation
### Binary
There are prebuilt binary files for this application. They are statically
linked and have no additional dependencies. Supported plattforms are:
* Windows (XP and up)
* Linux (2.6.16 and up)
* Linux ARM (for raspberry pi, 3.0 and up)
Simply download them from the "artifacts" section of this project.
### Docker
A prebuilt docker image (10MB) is available:
```bash
docker pull docker.klink.asia/paul/certman
```
### From Source-Docker
You can easily build your own docker image from source
```bash
docker build -t docker.klink.asia/paul/certman .
```
## Configuration
Certman assumes the root certificates of the VPN CA are located in the same
directory as the binary, If that is not the case you need to copy over the
`ca.crt` and `ca.key` files before you are able to generate certificates
with this tool.
Additionally, the project is configured by the following environment
variables:
* `OAUTH2_CLIENT_ID` the Client ID, assigned during client registration
* `OAUTH2_CLIENT_SECRET` the Client secret, assigned during client registration
* `OAUTH2_AUTH_URL` the URL to the "/authorize" endpoint of the identity provider
* `OAUTH2_TOKEN_URL` the URL to the "/token" endpoint of the identity provider
* `OAUTH2_REDIRECT_URL` the redirect URL used by the app, usually the hostname suffixed by "/login/oauth2/redirect"
* `USER_ENDPOINT` the URL to the Identity provider user endpoint, for gitlab this is "/api/v4/user". The "username" attribute of the returned JSON will used for authentication.
* `APP_KEY` random ASCII string, 32 characters in length. Used for cookie generation.
* `APP_LISTEN` port and ip to listen on, e.g. `:8000` or `127.0.0.1:3000`

28
assets/dev.go Normal file
View File

@ -0,0 +1,28 @@
// +build dev
package assets
import (
"go/build"
"log"
"net/http"
"github.com/shurcooL/httpfs/union"
)
// Assets contains project assets.
var Assets = union.New(map[string]http.FileSystem{
"/static": http.Dir(importPathToDir("git.klink.asia/paul/certman/assets/static")),
"/templates": http.Dir(importPathToDir("git.klink.asia/paul/certman/assets/templates")),
})
// importPathToDir is a helper function that resolves the absolute path of
// modules, so they can be used both in dev mode (`-tags="dev"`) or with a
// generated static asset file (`go generate`).
func importPathToDir(importPath string) string {
p, err := build.Import(importPath, "", build.FindOnly)
if err != nil {
log.Fatalln(err)
}
return p.Dir
}

6
assets/doc.go Normal file
View File

@ -0,0 +1,6 @@
//go:generate vfsgendev -source="git.klink.asia/paul/certman/assets".Assets
// Package assets contains assets the service, that will be embedded into
// the binary.
// Regenerate by running `go generate git.klink.asia/paul/certman/assets`.
package assets

View File

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
height="119.99999"
width="540"
xml:space="preserve"
viewBox="0 0 540 119.99999"
y="0px"
x="0px"
id="Ebene_1"
version="1.1"><metadata
id="metadata22"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title>ONEOFF</dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs20" /><style
id="style2"
type="text/css">
.st0{fill:#143C78;}
</style><g
transform="matrix(4,0,0,4,-365.2,-639.99996)"
id="XMLID_14_" /><title
id="title5">ONEOFF</title><desc
id="desc7">Created with Sketch.</desc><g
transform="matrix(1.1233618,0,0,1.0968921,-102.56293,-88.409503)"
id="XMLID_2_"><path
style="fill:#f9f9f9"
d="m 492.9,95.3 h -44 c -4.8,0 -8.7,3.9 -8.7,8.7 v 61.9 c 0,4.8 3.9,8.6 8.7,8.6 4.8,0 8.7,-3.8 8.7,-8.6 v -22.5 h 30.2 c 4.4,0 7.9,-3.5 7.9,-7.9 0,-4.4 -3.5,-7.9 -7.9,-7.9 H 457.6 V 111 h 35.2 c 4.4,0 7.9,-3.5 7.9,-7.9 0,-4.3 -3.5,-7.8 -7.8,-7.8 z"
class="st0"
id="XMLID_13_" /><path
style="fill:#f9f9f9"
d="m 564.1,111 c 4.4,0 7.9,-3.5 7.9,-7.9 0,-4.4 -3.5,-7.9 -7.9,-7.9 h -44 c -4.8,0 -8.4,3.9 -8.4,8.7 v 61.9 c 0,4.8 3.7,8.6 8.5,8.6 4.8,0 8.5,-3.8 8.5,-8.6 V 143.3 H 559 c 4.4,0 7.9,-3.5 7.9,-7.9 0,-4.4 -3.5,-7.9 -7.9,-7.9 H 528.7 V 111 Z"
class="st0"
id="XMLID_12_" /><path
style="fill:#f9f9f9"
d="m 386.6,94 c -21.6,0 -38.1,14.6 -41.3,33.6 h -14.9 c -3.9,0.1 -7,3.3 -7,7.2 0,3.9 3.1,7.1 7,7.2 h 14.9 c 3.3,18.9 19.5,33.1 41.1,33.1 24.2,0 41.9,-18.3 41.9,-40.6 0,-22.4 -17.5,-40.5 -41.7,-40.5 z m 23.4,40.8 c 0,13.5 -9.6,24.5 -23.5,24.5 -11.2,0 -19.8,-7.3 -22.7,-17.2 h 22.7 c 4,0 7.3,-3.1 7.3,-7.2 0,-4 -3.3,-7.3 -7.3,-7.3 h -22.9 c 2.6,-10.2 11.2,-17.7 22.7,-17.7 13.9,0 23.7,11.2 23.7,24.7 z"
class="st0"
id="XMLID_9_" /><path
style="fill:#f9f9f9"
d="m 342.5,80.7 -10.8,16 c -11.7,0 -49.1,-0.1 -49.3,-0.1 -3.8,0.2 -6.8,3.4 -6.8,7.2 0,4 3.2,7.2 7.2,7.2 0.1,0 29.7,0 39.1,0 l -11.1,16.4 c -8.6,0 -28.1,0 -28.2,0 h -0.2 v -0.1 c -3.8,0.1 -7,3.3 -7,7.2 0,3.9 3.1,7.1 7,7.2 H 301 l -11.7,17.4 h -6.5 c -4,0 -7.3,3.2 -7.3,7.2 0,3 1.9,5.6 4.5,6.7 l -11.4,17 h 1 L 343.3,80.6 h -0.8 z"
class="st0"
id="XMLID_8_" /><path
style="fill:#f9f9f9"
d="m 133.1,94 c -24.2,0 -41.8,18.3 -41.8,40.6 v 0.2 c 0,22.3 17.4,40.4 41.6,40.4 24.2,0 41.8,-18.3 41.8,-40.6 v -0.2 C 174.7,112.1 157.3,94 133.1,94 Z m 23.7,40.8 c 0,13.5 -9.8,24.5 -23.7,24.5 -13.9,0 -23.9,-11.2 -23.9,-24.7 v -0.2 c 0,-13.5 9.8,-24.5 23.7,-24.5 13.9,0 23.9,11.2 23.9,24.7 z"
class="st0"
id="XMLID_5_" /><circle
style="fill:#f9f9f9"
r="7.4000001"
cy="134.60001"
cx="132.5"
class="st0"
id="XMLID_4_" /><path
style="fill:#f9f9f9"
d="m 250.8,94.7 c -4.7,0 -8.5,3.8 -8.5,8.5 v 40.5 L 209.1,99.9 c -2.4,-3 -4.7,-5.1 -8.9,-5.1 h -1.8 c -4.8,0 -8.8,3.9 -8.8,8.7 V 166 c 0,4.7 4,8.5 8.7,8.5 4.7,0 8.7,-3.8 8.7,-8.5 v -42 l 34.5,45.4 c 2.4,3 4.7,5.1 8.9,5.1 h 0.6 c 4.8,0 8.5,-3.9 8.5,-8.8 v -62.5 c -0.1,-4.7 -3.9,-8.5 -8.7,-8.5 z"
class="st0"
id="XMLID_3_" /></g></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

6
assets/static/js/main.js Normal file
View File

@ -0,0 +1,6 @@
$.ajaxSetup({
// Initialize headers from the csrf-token meta tag.
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});

10663
assets/static/vendor/bulma.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,20 @@
{{ define "meta" }}
<title>Insufficient permissions</title>
{{ end }}
{{ define "content" }}
<section class="hero is-info is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns">
<div class="column is-8-desktop is-offset-2-desktop">
<h1 class="title is-2 is-spaced">Insufficient permissions</h1>
<h2 class="subtitle is-4">
Sorry, you don't have permissione to access this ressource.
</h2>
</div>
</div>
</div>
</div>
</section>
{{ end }}

View File

@ -0,0 +1,21 @@
{{ define "meta" }}
<title>Forbidden</title>
{{ end }}
{{ define "content" }}
<section class="hero is-warning is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns">
<div class="column is-8-desktop is-offset-2-desktop">
<h1 class="title is-2 is-spaced">Forbidden</h1>
<h2 class="subtitle is-4">
The received the request was not processed.
For example, the CSRF validation may have failed.
</h2>
</div>
</div>
</div>
</div>
</section>
{{ end }}

View File

@ -0,0 +1,20 @@
{{ define "meta" }}
<title>Page not found</title>
{{ end }}
{{ define "content" }}
<section class="hero is-info is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns">
<div class="column is-8-desktop is-offset-2-desktop">
<h1 class="title is-2 is-spaced">Page not found</h1>
<h2 class="subtitle is-4">
You requested a page that could not be fetched by the web server.
</h2>
</div>
</div>
</div>
</div>
</section>
{{ end }}

View File

@ -0,0 +1,21 @@
{{ define "meta" }}
<title>Sorry, something went wrong.</title>
{{ end }}
{{ define "content" }}
<section class="hero is-warning is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns">
<div class="column is-8-desktop is-offset-2-desktop">
<h1 class="title is-2 is-spaced">Sorry, there was an error while processing your request.</h1>
<h2 class="subtitle is-4">
Maybe you can try again at a later time.<br>
We have logged the error and will try to make sure that this does not happen in the future.
</h2>
</div>
</div>
</div>
</div>
</section>
{{ end }}

View File

@ -0,0 +1,69 @@
{{ define "base" }}# Client configuration for {{ .User }}@{{ .Name }}
client
dev tun
remote ovpn.zom.bi 1194 udp
resolv-retry infinite
nobind
persist-key
persist-tun
cipher AES-256-CBC
auth SHA512
ns-cert-type server
key-direction 1
tls-version-min 1.2
;comp-lzo
verb 3
<ca>
-----BEGIN CERTIFICATE-----
MIIDszCCApugAwIBAgIJAKuS+fLPTpC7MA0GCSqGSIb3DQEBBQUAMHAxCzAJBgNV
BAYTAkRFMQ8wDQYDVQQIDAZTYXhvbnkxEDAOBgNVBAcMB0RyZXNkZW4xDjAMBgNV
BAoMBVpvbWJpMRUwEwYDVQQLDAxJVC1BYnRlaWx1bmcxFzAVBgNVBAMMDmNhLWNl
cnRpZmljYXRlMB4XDTE0MDQyMzIxNDQ1NloXDTE5MDQyMjIxNDQ1NlowcDELMAkG
A1UEBhMCREUxDzANBgNVBAgMBlNheG9ueTEQMA4GA1UEBwwHRHJlc2RlbjEOMAwG
A1UECgwFWm9tYmkxFTATBgNVBAsMDElULUFidGVpbHVuZzEXMBUGA1UEAwwOY2Et
Y2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFW6ZC
zvTKCXoKKrb5ZKBWcQ55Fk57AJsMdqBv2TsTeDKPYONBh60cBgnhpFtkU8beetFT
1AQ0jwiyzaIqniSojBoUaWELxX5/w3qs9GPF4lkaBmZjQXnw1CRTZqJLccXmukIk
KkYKjMx+UVUAu6tMh5xMSml70ElDdHIgA33E+u1PvNW5hd3qQ+uNepqmKOROnjXb
Z6NghGR+26JmGRodKaPgY3VUe4s4QV1EnhaD8d43DgA0DsnrgO5X/RJVAwbXf9r6
Tqn6wE+v/VPH5xeo4RJegQuUfbY1A+z6odED+5OnNRuWQ3lp8GBfh7EThgBlfsin
jeFEkj6niOmCwnnnAgMBAAGjUDBOMB0GA1UdDgQWBBRKFc5tXx14eIKvtkEsi5EU
a0g1ADAfBgNVHSMEGDAWgBRKFc5tXx14eIKvtkEsi5EUa0g1ADAMBgNVHRMEBTAD
AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQAT2xjVkrQpzHsF3naK/yYCKykfNHKv9BZD
iubiunvNKM47PWPjOMJvwktbbSOoOEsA9yJwCJFB0U9MMRb+FQorfRrv3J6T+QpP
F2HruMkqf7Fd20Gjhrcerl+dfN2aJf4cC7podTiK0w0kskzApeXmILdeA1x5iu8R
DweGc77S1GXIB+ep6DRvTnZ0IR4y4h2gOwnFWQagMt+v8gx3iOn8zjf+qIflXbbO
rNPAsF0k4gtin6uALQRx4NH63jA6mYZIcgOR9fmJHGhsE6qPLMjZHe8JWyaTbcye
7JZ7/q02vooEWWBtkQwRgBQ/pXgYagzjW4237TgNT53vkrtU+fzt
-----END CERTIFICATE-----
</ca>
<cert>
{{ .Cert | html }}</cert>
<key>
{{ .Key | html }}</key>
<tls-auth>
-----BEGIN OpenVPN Static key V1-----
158130f54e0d350c24974fc0d28ea5a7
432e63ebfa30bf912dc0405a848767a5
53940a8fff3f9bd19e2fb7cdb9d9de60
8dbe2d849c01a9ac79c6843eb4362fc3
3718c68cd0d7966616c49dce38a6a9b9
e3d8ff7bb49ff6c236e5d8d87b6bb693
d6ced44bbc13755aeb85d8848d3c37fa
21b5bdd05333cdd4e785658c1e549097
213005010a27ecf025eda44b14401ae0
f0abfc74a0bdc1fa081be748b21f5a4c
3e5f15eb666e94978be136cac5245976
6559f70dc37306376e3f08ce6267c1bc
04bdfa14488a124a3a5c595287fd3013
50940b53c784092759601c3c3c51fb6e
e248834d37e6cba41f9e908b216930b5
2e040f8472b460b37d72cc648784d0b4
-----END OpenVPN Static key V1-----
</tls-auth>
{{ end }}

View File

@ -0,0 +1,45 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ .csrfToken }}" />
<meta name="csrf-param" content="csrf_token" />
<link rel="stylesheet" href='{{ asset "vendor/bulma.css" }}'>
<link rel="stylesheet" href='{{ asset "css/style.css" }}'>
{{ template "meta" . }}
{{ if eq .Meta.Env "production" }}
<!-- Add meta tags specific to production environment (analytics etc) -->
{{ end }}
<script defer src="https://use.fontawesome.com/releases/v5.0.6/js/all.js"
integrity="sha384-0AJY8UERsBUKdWcyF3o2kisLKeIo6G4Tbd8Y6fbyw6qYmn4WBuqcvxokp8m2UzSD"
crossorigin="anonymous"></script>
</head>
<body>
{{ if .flashes}}
<div id="flash-container">
{{range .flashes}}
{{ .Render }}
{{end}}
</div>
{{ end }}
<div class="main-container">
{{ template "content" . }}
</div>
<script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script>
<script>window.jQuery || document.write('<script src="{{ asset "vendor/jquery-3.2.1.min.js" }}"><\/script>')</script>
<script src='{{ asset "js/main.js" }}' async></script>
</body>
</html>
{{ end }}
{{ define "meta"}}{{end}}
{{ define "content"}}{{end}}

View File

@ -0,0 +1,56 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ .csrfToken }}" />
<meta name="csrf-param" content="csrf_token" />
<link rel="stylesheet" href='{{ asset "vendor/bulma.css" }}'>
<link rel="stylesheet" href='{{ asset "css/style.css" }}'>
{{ template "meta" . }}
{{ if eq .Meta.Env "production" }}
<!-- Add meta tags specific to production environment (analytics etc) -->
{{ end }}
<script defer src="https://use.fontawesome.com/releases/v5.0.6/js/all.js"
integrity="sha384-0AJY8UERsBUKdWcyF3o2kisLKeIo6G4Tbd8Y6fbyw6qYmn4WBuqcvxokp8m2UzSD"
crossorigin="anonymous"></script>
</head>
<body>
<section class="hero is-info is-bold is-fullheight">
{{ if .flashes}}
<div class="hero-head" id="flash-container">
{{range .flashes}}
{{ .Render }}
{{end}}
</div>
{{ end }}
<div class="hero-body">
<div class="container">
<div class="columns is-centered">
<div class="column is-one-third">
<div class="columns is-centered">
<div class="column is-half is-half-mobile is-offset-one-quarter-mobile">
<img src="{{ asset "img/logo.svg" }}"/>
</div>
</div>
{{ template "content" . }}
</div>
</div>
</div>
</div>
</section>
<script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script>
<script>window.jQuery || document.write('<script src="{{ asset "vendor/jquery-3.2.1.min.js" }}"><\/script>')</script>
<script src='{{ asset "js/main.js" }}' async></script>
</body>
</html>
{{ end }}
{{ define "meta"}}{{end}}

View File

@ -0,0 +1,68 @@
{{ define "meta" }}
<title>Certificate List</title>
{{ end}}
{{ define "content" }}
<section class="content">
<div class="section">
<div class="container">
<div class="columns">
<div class="column">
<h1 class="title">Certificates for {{ .username }}:</h1>
<table class="table">
<thead>
<th>Device</th>
<th width="20%">Created</th>
<th width="20%" class="has-text-centered">Actions</th>
</thead>
<tfoot>
<form action="/certs/new" method="POST">
<th colspan="2">
<div class="field has-addons">
<p class="control is-marginless">
<a class="button is-static">
{{ $.username }}@
</a>
</p>
<p class="control is-marginless is-expanded">
<input name="certname" class="input" type="text" placeholder="Certificate name (e.g. Laptop)">
</p>
</div>
</th>
<th>{{ .csrfField }}<input type="submit" class="button is-success is-fullwidth" value="Create"/></th>
</form>
</tfoot>
<tbody>
{{ range .Clients }}
<tr>
<td class="is-vcentered"><p>{{ .User }}@{{ .Name }}</p></td>
<td><time title="{{ .CreatedAt.UTC }}">{{ .CreatedAt | humanDate }}</time></td>
<td>
<div class="field has-addons">
<p class="control is-marginless is-expanded">
<a href="/certs/download/{{ .Name }}" class="button is-primary is-fullwidth">Download</a>
</p>
<div class="control is-marginless">
<form action="/certs/delete/{{ .Name }}" method="POST">
{{ $.csrfField }}
<button class="button is-danger" type="submit">
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
</button>
</form>
</div>
</div>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
{{ end}}

View File

@ -0,0 +1,41 @@
{{ define "meta" }}<title>Log In</title>{{ end }}
{{ define "content" }}
<div class="box is-shadowless">
<div class="content has-text-centered" style="padding: 0 40px;">
<h1 class="title has-text-dark">Log In</h1>
<form action="" method="POST" class="control">
<div class="field">
<label for="email" class="label is-hidden">Email Address</label>
<div class="control has-icons-left">
<input class="input is-medium is-shadowless" id="email" name="email" spellcheck="false" label="false" type="" placeholder="Email Address" value="" autofocus />
<span class="icon is-medium is-left">
<i class="fas fa-at"></i>
</span>
</div>
</div>
<div class="field">
<label for="password" class="label is-hidden">Password</label>
<div class="control has-icons-left">
<input class="input is-medium is-shadowless" id="password" name="password" type="password" placeholder="Password" value=""/>
<span class="icon is-medium is-left">
<i class="fas fa-key"></i>
</span>
</div>
</div>
{{ .csrfField }}
<div class="field is-grouped-right">
<input class="button is-success is-fullwidth is-medium" type="submit" value="Log In">
</div>
<div class="field has-text-centered">
<p><a href="/forgot-password">Forgot Password?</a></p>
</div>
</form>
</div>
</div>
<div class="field">
<p class="has-text-white has-text-centered">
Don't have an account? <a class="has-text-weight-bold" href="/register">Sign Up</a>
</p>
</div>
{{ end }}

75
handlers/auth.go Normal file
View File

@ -0,0 +1,75 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"git.klink.asia/paul/certman/views"
"golang.org/x/oauth2"
"git.klink.asia/paul/certman/services"
)
func OAuth2Endpoint(p *services.Provider, config *oauth2.Config) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.NewWithSession(req, p.Sessions)
code := req.FormValue("code")
// exchange code for token
accessToken, err := config.Exchange(oauth2.NoContext, code)
if err != nil {
fmt.Println(err)
http.NotFound(w, req)
return
}
if accessToken.Valid() {
// generate a client using the access token
httpClient := config.Client(oauth2.NoContext, accessToken)
apiRequest, err := http.NewRequest("GET", os.Getenv("USER_ENDPOINT"), nil)
if err != nil {
v.RenderError(w, http.StatusNotFound)
return
}
resp, err := httpClient.Do(apiRequest)
if err != nil {
fmt.Println(err.Error())
v.RenderError(w, http.StatusInternalServerError)
return
}
var user struct {
Username string `json:"username"`
}
err = json.NewDecoder(resp.Body).Decode(&user)
if err != nil {
fmt.Println(err.Error())
v.RenderError(w, http.StatusInternalServerError)
return
}
if user.Username != "" {
p.Sessions.SetUsername(w, req, user.Username)
http.Redirect(w, req, "/certs", http.StatusFound)
return
}
fmt.Println(err.Error())
v.RenderError(w, http.StatusInternalServerError)
return
}
}
}
func GetLoginHandler(p *services.Provider, config *oauth2.Config) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
authURL := config.AuthCodeURL("", oauth2.AccessTypeOnline)
http.Redirect(w, req, authURL, http.StatusFound)
}
}

253
handlers/cert.go Normal file
View File

@ -0,0 +1,253 @@
package handlers
import (
"bytes"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"html/template"
"io/ioutil"
"log"
"math/big"
"net/http"
"strings"
"time"
"git.klink.asia/paul/certman/models"
"git.klink.asia/paul/certman/services"
"github.com/go-chi/chi"
"git.klink.asia/paul/certman/views"
)
func ListClientsHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.NewWithSession(req, p.Sessions)
username := p.Sessions.GetUsername(req)
clients, _ := p.ClientCollection.ListClientsForUser(username)
v.Vars["Clients"] = clients
v.Render(w, "client_list")
}
}
func CreateCertHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
username := p.Sessions.GetUsername(req)
certname := req.FormValue("certname")
// Validate certificate Name
if !IsByteLength(certname, 2, 64) || !IsDNSName(certname) {
p.Sessions.Flash(w, req,
services.Flash{
Type: "danger",
Message: "The certificate name can only contain letters and numbers",
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
return
}
// lowercase the certificate name, to avoid problems with the case
// insensitive matching inside OpenVPN
certname = strings.ToLower(certname)
// Load CA master certificate
caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key")
if err != nil {
log.Fatalf("error loading ca keyfiles: %s", err)
panic(err.Error())
}
// Generate Keypair
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Fatalf("Could not generate keypair: %s", err)
p.Sessions.Flash(w, req,
services.Flash{
Type: "danger",
Message: "The certificate key could not be generated",
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
return
}
// Generate Certificate
commonName := fmt.Sprintf("%s@%s", username, certname)
derBytes, err := CreateCertificate(commonName, key, caCert, caKey)
// Initialize new client config
client := models.Client{
Name: certname,
CreatedAt: time.Now(),
PrivateKey: x509.MarshalPKCS1PrivateKey(key),
Cert: derBytes,
User: username,
}
// Insert client into database
if err := p.ClientCollection.CreateClient(&client); err != nil {
log.Println(err.Error())
p.Sessions.Flash(w, req,
services.Flash{
Type: "danger",
Message: "The certificate could not be added to the database",
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
return
}
p.Sessions.Flash(w, req,
services.Flash{
Type: "success",
Message: "The certificate was created successfully",
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
}
}
func DeleteCertHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.New(req)
// detemine own username
username := p.Sessions.GetUsername(req)
name := chi.URLParam(req, "name")
client, err := p.ClientCollection.GetClientByNameUser(name, username)
if err != nil {
v.RenderError(w, http.StatusNotFound)
return
}
err = p.ClientCollection.DeleteClient(client.ID)
if err != nil {
p.Sessions.Flash(w, req,
services.Flash{
Type: "danger",
Message: "Failed to delete certificate",
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
}
p.Sessions.Flash(w, req,
services.Flash{
Type: "success",
Message: template.HTML(fmt.Sprintf("Successfully deleted client <strong>%s</strong>", client.Name)),
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
}
}
func DownloadCertHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.New(req)
// detemine own username
username := p.Sessions.GetUsername(req)
name := chi.URLParam(req, "name")
client, err := p.ClientCollection.GetClientByNameUser(name, username)
if err != nil {
v.RenderError(w, http.StatusNotFound)
return
}
// cbuf and kbuf are buffers in which the PEM certificates are
// rendered into
var cbuf = new(bytes.Buffer)
var kbuf = new(bytes.Buffer)
pem.Encode(cbuf, &pem.Block{Type: "CERTIFICATE", Bytes: client.Cert})
pem.Encode(kbuf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: client.PrivateKey})
vars := map[string]string{
"Cert": cbuf.String(),
"Key": kbuf.String(),
}
t, err := views.GetTemplate("config.ovpn")
if err != nil {
log.Printf("Error loading certificate template: %s", err)
v.RenderError(w, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/x-openvpn-profile")
w.Header().Set("Content-Disposition", "attachment; filename=\"config.ovpn\"")
w.WriteHeader(http.StatusOK)
t.Execute(w, vars)
return
}
}
func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateKey, error) {
cf, err := ioutil.ReadFile(certFile)
if err != nil {
return nil, nil, err
}
kf, err := ioutil.ReadFile(keyFile)
if err != nil {
return nil, nil, err
}
cpb, _ := pem.Decode(cf)
kpb, _ := pem.Decode(kf)
crt, err := x509.ParseCertificate(cpb.Bytes)
if err != nil {
return nil, nil, err
}
key, err := x509.ParsePKCS1PrivateKey(kpb.Bytes)
if err != nil {
return nil, nil, err
}
return crt, key, nil
}
// CreateCertificate creates a CA-signed certificate
func CreateCertificate(commonName string, key interface{}, caCert *x509.Certificate, caKey interface{}) ([]byte, error) {
subj := caCert.Subject
// .. except for the common name
subj.CommonName = commonName
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Fatalf("Obscure error in cert serial number generation: %s", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: subj,
NotBefore: time.Now().Add(-5 * time.Minute), // account for clock shift
NotAfter: time.Now().Add(24 * time.Hour * 356 * 5), // 5 years ought to be enough!
SignatureAlgorithm: x509.SHA256WithRSA,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
return x509.CreateCertificate(rand.Reader, &template, caCert, publicKey(key), caKey)
}
func publicKey(priv interface{}) interface{} {
switch k := priv.(type) {
case *rsa.PrivateKey:
return &k.PublicKey
case *ecdsa.PrivateKey:
return &k.PublicKey
default:
return nil
}
}

64
handlers/converters.go Normal file
View File

@ -0,0 +1,64 @@
package handlers
import (
"encoding/json"
"fmt"
"reflect"
"strconv"
)
// ToString convert the input to a string.
func ToString(obj interface{}) string {
res := fmt.Sprintf("%v", obj)
return string(res)
}
// ToJSON convert the input to a valid JSON string
func ToJSON(obj interface{}) (string, error) {
res, err := json.Marshal(obj)
if err != nil {
res = []byte("")
}
return string(res), err
}
// ToFloat convert the input string to a float, or 0.0 if the input is not a float.
func ToFloat(str string) (float64, error) {
res, err := strconv.ParseFloat(str, 64)
if err != nil {
res = 0.0
}
return res, err
}
// ToInt convert the input string or any int type to an integer type 64, or 0 if the input is not an integer.
func ToInt(value interface{}) (res int64, err error) {
val := reflect.ValueOf(value)
switch value.(type) {
case int, int8, int16, int32, int64:
res = val.Int()
case uint, uint8, uint16, uint32, uint64:
res = int64(val.Uint())
case string:
if IsInt(val.String()) {
res, err = strconv.ParseInt(val.String(), 0, 64)
if err != nil {
res = 0
}
} else {
err = fmt.Errorf("math: square root of negative number %g", value)
res = 0
}
default:
err = fmt.Errorf("math: square root of negative number %g", value)
res = 0
}
return
}
// ToBoolean convert the input string to a boolean.
func ToBoolean(str string) (bool, error) {
return strconv.ParseBool(str)
}

22
handlers/handlers.go Normal file
View File

@ -0,0 +1,22 @@
package handlers
import (
"net/http"
"git.klink.asia/paul/certman/views"
)
func NotFoundHandler(w http.ResponseWriter, req *http.Request) {
view := views.New(req)
view.RenderError(w, http.StatusNotFound)
}
func ErrorHandler(w http.ResponseWriter, req *http.Request) {
view := views.New(req)
view.RenderError(w, http.StatusInternalServerError)
}
func CSRFErrorHandler(w http.ResponseWriter, req *http.Request) {
view := views.New(req)
view.RenderError(w, http.StatusForbidden)
}

138
handlers/validators.go Normal file
View File

@ -0,0 +1,138 @@
package handlers
import (
"net"
"regexp"
"strings"
)
const (
Email string = "^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$"
CreditCard string = "^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\\d{11})$"
ISBN10 string = "^(?:[0-9]{9}X|[0-9]{10})$"
ISBN13 string = "^(?:[0-9]{13})$"
UUID3 string = "^[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$"
UUID4 string = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
UUID5 string = "^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
UUID string = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
Alpha string = "^[a-zA-Z]+$"
Alphanumeric string = "^[a-zA-Z0-9]+$"
Numeric string = "^[0-9]+$"
Int string = "^(?:[-+]?(?:0|[1-9][0-9]*))$"
Float string = "^(?:[-+]?(?:[0-9]+))?(?:\\.[0-9]*)?(?:[eE][\\+\\-]?(?:[0-9]+))?$"
Hexadecimal string = "^[0-9a-fA-F]+$"
Hexcolor string = "^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
RGBcolor string = "^rgb\\(\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*,\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*,\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*\\)$"
ASCII string = "^[\x00-\x7F]+$"
Multibyte string = "[^\x00-\x7F]"
FullWidth string = "[^\u0020-\u007E\uFF61-\uFF9F\uFFA0-\uFFDC\uFFE8-\uFFEE0-9a-zA-Z]"
HalfWidth string = "[\u0020-\u007E\uFF61-\uFF9F\uFFA0-\uFFDC\uFFE8-\uFFEE0-9a-zA-Z]"
Base64 string = "^(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=|[A-Za-z0-9+\\/]{4})$"
PrintableASCII string = "^[\x20-\x7E]+$"
DataURI string = "^data:.+\\/(.+);base64$"
Latitude string = "^[-+]?([1-8]?\\d(\\.\\d+)?|90(\\.0+)?)$"
Longitude string = "^[-+]?(180(\\.0+)?|((1[0-7]\\d)|([1-9]?\\d))(\\.\\d+)?)$"
DNSName string = `^([a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62}){1}(\.[a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62})*[\._]?$`
IP string = `(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))`
URLSchema string = `((ftp|tcp|udp|wss?|https?):\/\/)`
URLUsername string = `(\S+(:\S*)?@)`
URLPath string = `((\/|\?|#)[^\s]*)`
URLPort string = `(:(\d{1,5}))`
URLIP string = `([1-9]\d?|1\d\d|2[01]\d|22[0-3])(\.(1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.([0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))`
URLSubdomain string = `((www\.)|([a-zA-Z0-9]([-\.][-\._a-zA-Z0-9]+)*))`
URL string = `^` + URLSchema + `?` + URLUsername + `?` + `((` + URLIP + `|(\[` + IP + `\])|(([a-zA-Z0-9]([a-zA-Z0-9-_]+)?[a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*)|(` + URLSubdomain + `?))?(([a-zA-Z\x{00a1}-\x{ffff}0-9]+-?-?)*[a-zA-Z\x{00a1}-\x{ffff}0-9]+)(?:\.([a-zA-Z\x{00a1}-\x{ffff}]{1,}))?))\.?` + URLPort + `?` + URLPath + `?$`
SSN string = `^\d{3}[- ]?\d{2}[- ]?\d{4}$`
WinPath string = `^[a-zA-Z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*$`
UnixPath string = `^(/[^/\x00]*)+/?$`
Semver string = "^v?(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(-(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\\+[0-9a-zA-Z-]+(\\.[0-9a-zA-Z-]+)*)?$"
tagName string = "valid"
hasLowerCase string = ".*[[:lower:]]"
hasUpperCase string = ".*[[:upper:]]"
)
var (
rxEmail = regexp.MustCompile(Email)
rxCreditCard = regexp.MustCompile(CreditCard)
rxISBN10 = regexp.MustCompile(ISBN10)
rxISBN13 = regexp.MustCompile(ISBN13)
rxUUID3 = regexp.MustCompile(UUID3)
rxUUID4 = regexp.MustCompile(UUID4)
rxUUID5 = regexp.MustCompile(UUID5)
rxUUID = regexp.MustCompile(UUID)
rxAlpha = regexp.MustCompile(Alpha)
rxAlphanumeric = regexp.MustCompile(Alphanumeric)
rxNumeric = regexp.MustCompile(Numeric)
rxInt = regexp.MustCompile(Int)
rxFloat = regexp.MustCompile(Float)
rxHexadecimal = regexp.MustCompile(Hexadecimal)
rxHexcolor = regexp.MustCompile(Hexcolor)
rxRGBcolor = regexp.MustCompile(RGBcolor)
rxASCII = regexp.MustCompile(ASCII)
rxPrintableASCII = regexp.MustCompile(PrintableASCII)
rxMultibyte = regexp.MustCompile(Multibyte)
rxFullWidth = regexp.MustCompile(FullWidth)
rxHalfWidth = regexp.MustCompile(HalfWidth)
rxBase64 = regexp.MustCompile(Base64)
rxDataURI = regexp.MustCompile(DataURI)
rxLatitude = regexp.MustCompile(Latitude)
rxLongitude = regexp.MustCompile(Longitude)
rxDNSName = regexp.MustCompile(DNSName)
rxURL = regexp.MustCompile(URL)
rxSSN = regexp.MustCompile(SSN)
rxWinPath = regexp.MustCompile(WinPath)
rxUnixPath = regexp.MustCompile(UnixPath)
rxSemver = regexp.MustCompile(Semver)
rxHasLowerCase = regexp.MustCompile(hasLowerCase)
rxHasUpperCase = regexp.MustCompile(hasUpperCase)
)
// IsAlphanumeric check if the string contains only letters and numbers. Empty string is valid.
func IsAlphanumeric(str string) bool {
if IsNull(str) {
return true
}
return rxAlphanumeric.MatchString(str)
}
// ByteLength check string's length
func ByteLength(str string, params ...string) bool {
if len(params) == 2 {
min, _ := ToInt(params[0])
max, _ := ToInt(params[1])
return len(str) >= int(min) && len(str) <= int(max)
}
return false
}
// IsInt check if the string is an integer. Empty string is valid.
func IsInt(str string) bool {
if IsNull(str) {
return true
}
return rxInt.MatchString(str)
}
// IsNull check if the string is null.
func IsNull(str string) bool {
return len(str) == 0
}
// IsByteLength check if the string's length (in bytes) falls in a range.
func IsByteLength(str string, min, max int) bool {
return len(str) >= min && len(str) <= max
}
// IsDNSName will validate the given string as a DNS name
func IsDNSName(str string) bool {
if str == "" || len(strings.Replace(str, ".", "", -1)) > 255 {
// constraints already violated
return false
}
return !IsIP(str) && rxDNSName.MatchString(str)
}
// IsIP checks if a string is either IP version 4 or 6.
func IsIP(str string) bool {
return net.ParseIP(str) != nil
}

53
main.go Normal file
View File

@ -0,0 +1,53 @@
package main
import (
"errors"
"log"
"net/http"
"os"
"time"
"git.klink.asia/paul/certman/services"
"git.klink.asia/paul/certman/router"
"git.klink.asia/paul/certman/views"
)
func main() {
log.Println("Initializing certman")
if err := checkCAFilesExist(); err != nil {
log.Fatalf("Could not read CA files: %s", err)
}
c := services.Config{
CollectionPath: "./clients.json",
Sessions: &services.SessionsConfig{
SessionName: "_session",
CookieKey: os.Getenv("APP_KEY"),
HttpOnly: true,
Lifetime: 24 * time.Hour,
},
}
log.Println(".. services")
serviceProvider := services.NewProvider(&c)
// load and parse template files
log.Println(".. templates")
views.LoadTemplates()
mux := router.HandleRoutes(serviceProvider)
log.Println(".. server")
err := http.ListenAndServe(os.Getenv("APP_LISTEN"), mux)
log.Fatalf(err.Error())
}
func checkCAFilesExist() error {
for _, filename := range []string{"ca.crt", "ca.key"} {
if _, err := os.Stat(filename); os.IsNotExist(err) {
return errors.New(filename + " not readable")
}
}
return nil
}

26
middleware/panic.go Normal file
View File

@ -0,0 +1,26 @@
package middleware
import (
"log"
"net/http"
"runtime/debug"
"git.klink.asia/paul/certman/handlers"
)
// Recoverer Listens for panic() calls and logs the stacktrace.
func Recoverer(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rvr := recover(); rvr != nil {
log.Println(rvr)
log.Println(string(debug.Stack()))
handlers.ErrorHandler(w, r)
}
}()
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}

View File

@ -0,0 +1,22 @@
package middleware
import (
"net/http"
"git.klink.asia/paul/certman/services"
)
// RequireLogin is a middleware that checks for a username in the active
// session, and redirects to `/login` if no username was found.
func RequireLogin(sessions *services.Sessions) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, req *http.Request) {
if username := sessions.GetUsername(req); username == "" {
http.Redirect(w, req, "/login", http.StatusFound)
}
next.ServeHTTP(w, req)
}
return http.HandlerFunc(fn)
}
}

31
models/model.go Normal file
View File

@ -0,0 +1,31 @@
package models
import (
"errors"
"time"
)
var (
// ErrNotImplemented gets thrown if some action was not attempted,
// because it is not implemented in the code yet.
ErrNotImplemented = errors.New("Not implemented")
)
// Client represent the OpenVPN client configuration
type Client struct {
ID uint
CreatedAt time.Time
Name string
User string
Cert []byte
PrivateKey []byte
}
type ClientProvider interface {
CountClients() (uint, error)
CreateClient(*Client) (*Client, error)
ListClients(count, offset int) ([]*Client, error)
ListClientsForUser(user string, count, offset int) ([]*Client, error)
GetClientByID(id uint) (*Client, error)
DeleteClient(id uint) error
}

118
router/router.go Normal file
View File

@ -0,0 +1,118 @@
package router
import (
"net/http"
"os"
"strings"
"git.klink.asia/paul/certman/services"
"golang.org/x/oauth2"
"git.klink.asia/paul/certman/assets"
"git.klink.asia/paul/certman/handlers"
"git.klink.asia/paul/certman/views"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/gorilla/csrf"
mw "git.klink.asia/paul/certman/middleware"
)
var (
// TODO: make this configurable
csrfCookieName = "csrf"
csrfFieldName = "csrf_token"
csrfKey = []byte("7Oj4DllZ9lTsxJnisTuWiiQBGQIzi6gX")
cookieKey = []byte("osx70sMD8HZG2ouUl8uKI4wcMugiJ2WH")
)
func HandleRoutes(provider *services.Provider) http.Handler {
mux := chi.NewMux()
//mux.Use(middleware.RequestID)
mux.Use(middleware.Logger) // log requests
mux.Use(middleware.RealIP) // use proxy headers
mux.Use(middleware.RedirectSlashes) // redirect trailing slashes
mux.Use(mw.Recoverer) // recover on panic
mux.Use(provider.Sessions.Manager.Use) // use session storage
// TODO: move this code away from here
oauth2Config := &oauth2.Config{
ClientID: os.Getenv("OAUTH2_CLIENT_ID"),
ClientSecret: os.Getenv("OAUTH2_CLIENT_SECRET"),
Scopes: []string{"read_user"},
RedirectURL: os.Getenv("OAUTH2_REDIRECT_URL"),
Endpoint: oauth2.Endpoint{
AuthURL: os.Getenv("OAUTH2_AUTH_URL"),
TokenURL: os.Getenv("OAUTH2_TOKEN_URL"),
},
}
// we are serving the static files directly from the assets package
// this either means we use the embedded files, or live-load
// from the file system (if `--tags="dev"` is used).
fileServer(mux, "/static", assets.Assets)
mux.Route("/", func(r chi.Router) {
if os.Getenv("ENVIRONMENT") != "test" {
r.Use(csrf.Protect(
csrfKey,
csrf.Secure(false),
csrf.CookieName(csrfCookieName),
csrf.FieldName(csrfFieldName),
csrf.ErrorHandler(http.HandlerFunc(handlers.CSRFErrorHandler)),
))
}
r.HandleFunc("/", http.RedirectHandler("certs", http.StatusFound).ServeHTTP)
r.Route("/login", func(r chi.Router) {
r.Get("/", handlers.GetLoginHandler(provider, oauth2Config))
r.Get("/oauth2/redirect", handlers.OAuth2Endpoint(provider, oauth2Config))
})
r.Route("/certs", func(r chi.Router) {
r.Use(mw.RequireLogin(provider.Sessions))
r.Get("/", handlers.ListClientsHandler(provider))
r.Post("/new", handlers.CreateCertHandler(provider))
r.HandleFunc("/download/{name}", handlers.DownloadCertHandler(provider))
r.Post("/delete/{name}", handlers.DeleteCertHandler(provider))
})
r.Get("/unconfigured-backend", handlers.NotFoundHandler)
})
// what should happen if no route matches
mux.NotFound(handlers.NotFoundHandler)
return mux
}
// v is a helper function for quickly displaying a view
func v(template string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
view := views.New(req)
view.Render(w, template)
}
}
// fileServer sets up a http.FileServer handler to serve
// static files from a http.FileSystem.
func fileServer(r chi.Router, path string, root http.FileSystem) {
if strings.ContainsAny(path, "{}*") {
panic("FileServer does not permit URL parameters.")
}
//fs := http.StripPrefix(path, http.FileServer(root))
fs := http.FileServer(root)
if path != "/" && path[len(path)-1] != '/' {
r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP)
path += "/"
}
path += "*"
r.Get(path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fs.ServeHTTP(w, r)
}))
}

169
services/clientstore.go Normal file
View File

@ -0,0 +1,169 @@
package services
import (
"encoding/json"
"errors"
"io/ioutil"
"log"
"os"
"sync"
"git.klink.asia/paul/certman/models"
)
var (
ErrNilCertificate = errors.New("Trying to store nil certificate")
ErrDuplicate = errors.New("Client with that name already exists")
ErrUserNotExists = errors.New("User does not exist")
ErrClientNotExists = errors.New("Client does not exist")
)
type ClientCollection struct {
sync.RWMutex
path string
Clients map[uint]*models.Client
UserIndex map[string]map[string]uint
LastID uint
}
func NewClientCollection(path string) *ClientCollection {
// empty collection
var clientCollection = ClientCollection{
path: path,
Clients: make(map[uint]*models.Client),
UserIndex: make(map[string]map[string]uint),
LastID: 0,
}
raw, err := ioutil.ReadFile(path)
if os.IsNotExist(err) {
return &clientCollection
} else if err != nil {
log.Println(err)
return &clientCollection
}
if err := json.Unmarshal(raw, &clientCollection); err != nil {
log.Println(err)
}
return &clientCollection
}
// CreateClient inserts a client into the datastore
func (db *ClientCollection) CreateClient(client *models.Client) error {
db.Lock()
defer db.Unlock()
if client == nil {
return ErrNilCertificate
}
db.LastID++ // increment Id
client.ID = db.LastID
userIndex, exists := db.UserIndex[client.User]
if !exists {
// create user index if not exists
db.UserIndex[client.User] = make(map[string]uint)
userIndex = db.UserIndex[client.User]
}
if _, exists = userIndex[client.Name]; exists {
return ErrDuplicate
}
// if all went well, add client and set the index
db.Clients[client.ID] = client
userIndex[client.Name] = client.ID
db.UserIndex[client.User] = userIndex
return db.save()
}
// ListClientsForUser returns a slice of 'count' client for user 'user', starting at 'offset'
func (db *ClientCollection) ListClientsForUser(user string) ([]*models.Client, error) {
db.RLock()
defer db.RUnlock()
var clients = make([]*models.Client, 0)
userIndex, exists := db.UserIndex[user]
if !exists {
return nil, errors.New("user does not exist")
}
for _, clientID := range userIndex {
clients = append(clients, db.Clients[clientID])
}
return clients, nil
}
// GetClientByID returns a single client by ID
func (db *ClientCollection) GetClientByID(id uint) (*models.Client, error) {
client, exists := db.Clients[id]
if !exists {
return nil, ErrClientNotExists
}
return client, nil
}
// GetClientByNameUser returns a single client by ID
func (db *ClientCollection) GetClientByNameUser(name, user string) (*models.Client, error) {
db.RLock()
defer db.RUnlock()
userIndex, exists := db.UserIndex[user]
if !exists {
return nil, ErrUserNotExists
}
clientID, exists := userIndex[name]
if !exists {
return nil, ErrClientNotExists
}
client, exists := db.Clients[clientID]
if !exists {
return nil, ErrClientNotExists
}
return client, nil
}
// DeleteClient removes a client from the datastore
func (db *ClientCollection) DeleteClient(id uint) error {
db.Lock()
defer db.Unlock()
client, exists := db.Clients[id]
if !exists {
return nil // nothing to delete
}
userIndex, exists := db.UserIndex[client.User]
if !exists {
return ErrUserNotExists
}
delete(userIndex, client.Name) // delete client index
// if index is now empty, delete the user entry
if len(userIndex) == 0 {
delete(db.UserIndex, client.User)
}
// finally delete the client
delete(db.Clients, id)
return db.save()
}
func (c *ClientCollection) save() error {
collectionJSON, _ := json.Marshal(c)
return ioutil.WriteFile(c.path, collectionJSON, 0600)
}

21
services/provider.go Normal file
View File

@ -0,0 +1,21 @@
package services
type Config struct {
CollectionPath string
Sessions *SessionsConfig
}
type Provider struct {
ClientCollection *ClientCollection
Sessions *Sessions
}
// NewProvider returns the ServiceProvider
func NewProvider(conf *Config) *Provider {
var provider = &Provider{}
provider.ClientCollection = NewClientCollection(conf.CollectionPath)
provider.Sessions = NewSessions(conf.Sessions)
return provider
}

123
services/sessions.go Normal file
View File

@ -0,0 +1,123 @@
package services
import (
"encoding/gob"
"fmt"
"html/template"
"log"
"net/http"
"time"
"github.com/alexedwards/scs"
)
var (
// FlashesKey is the key used for the flashes in the cookie
FlashesKey = "_flashes"
// UserEmailKey is the key used to reference usernames
UserEmailKey = "_user_email"
)
func init() {
// Register the Flash message type, so gob can serialize it
gob.Register(Flash{})
}
type SessionsConfig struct {
SessionName string
CookieKey string
HttpOnly bool
Secure bool
Lifetime time.Duration
}
// Sessions is a wrapped scs.Store in order to implement custom logic
type Sessions struct {
*scs.Manager
}
// NewSessions populates the default sessions Store
func NewSessions(conf *SessionsConfig) *Sessions {
store := scs.NewCookieManager(
conf.CookieKey,
)
store.Name(conf.SessionName)
store.HttpOnly(true)
store.Lifetime(conf.Lifetime)
store.Secure(conf.Secure)
return &Sessions{store}
}
func (store *Sessions) GetUsername(req *http.Request) string {
if store == nil {
// if store was not initialized, all requests fail
log.Println("Nil pointer when checking session for username")
return ""
}
sess := store.Load(req)
email, err := sess.GetString(UserEmailKey)
if err != nil {
// Username found
return ""
}
// User is logged in
return email
}
func (store *Sessions) SetUsername(w http.ResponseWriter, req *http.Request, username string) {
if store == nil {
// if store was not initialized, do nothing
return
}
sess := store.Load(req)
// renew token to avoid session pinning/fixation attack
sess.RenewToken(w)
sess.PutString(w, UserEmailKey, username)
}
type Flash struct {
Message template.HTML
Type string
}
// Render renders the flash message as a notification box
func (flash Flash) Render() template.HTML {
return template.HTML(
fmt.Sprintf(
"<div class=\"notification is-radiusless is-%s\"><div class=\"container has-text-centered\">%s</div></div>",
flash.Type, flash.Message,
),
)
}
// Flash add flash message to session data
func (store *Sessions) Flash(w http.ResponseWriter, req *http.Request, flash Flash) error {
var flashes []Flash
sess := store.Load(req)
if err := sess.GetObject(FlashesKey, &flashes); err != nil {
return err
}
flashes = append(flashes, flash)
return sess.PutObject(w, FlashesKey, flashes)
}
// Flashes returns a slice of flash messages from session data
func (store *Sessions) Flashes(w http.ResponseWriter, req *http.Request) []Flash {
var flashes []Flash
sess := store.Load(req)
sess.PopObject(w, FlashesKey, &flashes)
return flashes
}

10
settings/settings.go Normal file
View File

@ -0,0 +1,10 @@
package settings
import "os"
func Get(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}

79
views/funcs.go Normal file
View File

@ -0,0 +1,79 @@
package views
import (
"fmt"
"html/template"
"strings"
"time"
)
var funcs = template.FuncMap{
"asset": assetURLFn,
"url": relURLFn,
"lower": lower,
"upper": upper,
"date": dateFn,
"humanDate": readableDateFn,
"t": translateFn,
}
func lower(input string) string {
return strings.ToLower(input)
}
func upper(input string) string {
return strings.ToUpper(input)
}
func assetURLFn(input string) string {
url := "/static/" //os.Getenv("ASSET_URL")
return fmt.Sprintf("%s%s", url, input)
}
func relURLFn(input string) string {
url := "/" //os.Getenv("ASSET_URL")
return fmt.Sprintf("%s%s", url, input)
}
func dateFn(format string, input interface{}) string {
var t time.Time
switch date := input.(type) {
default:
t = time.Now()
case time.Time:
t = date
}
return t.Format(format)
}
func translateFn(language string, text string) string {
return text
}
func readableDateFn(t time.Time) string {
if time.Now().Before(t) {
return "in the future"
}
diff := time.Now().Sub(t)
day := 24 * time.Hour
month := 30 * day
year := 12 * month
switch {
case diff < time.Second:
return "just now"
case diff < 5*time.Minute:
return "a few minutes ago"
case diff < time.Hour:
return fmt.Sprintf("%d minutes ago", diff/time.Minute)
case diff < day:
return fmt.Sprintf("%d hours ago", diff/time.Hour)
case diff < month:
return fmt.Sprintf("%d days ago", diff/day)
case diff < year:
return fmt.Sprintf("%d months ago", diff/month)
default:
return fmt.Sprintf("%d years ago", diff/year)
}
}

92
views/templates.go Normal file
View File

@ -0,0 +1,92 @@
package views
import (
"bytes"
"fmt"
"html/template"
"log"
"net/http"
"path/filepath"
"git.klink.asia/paul/certman/assets"
)
// map of all parsed templates, by template name
var templates map[string]*template.Template
// LoadTemplates initializes the templates map, parsing all defined templates.
func LoadTemplates() {
templates = map[string]*template.Template{
"401": newTemplate("layouts/application.gohtml", "errors/401.gohtml"),
"403": newTemplate("layouts/application.gohtml", "errors/403.gohtml"),
"404": newTemplate("layouts/application.gohtml", "errors/404.gohtml"),
"500": newTemplate("layouts/application.gohtml", "errors/500.gohtml"),
"login": newTemplate("layouts/auth.gohtml", "views/login.gohtml"),
"client_list": newTemplate("layouts/application.gohtml", "views/client_list.gohtml"),
"config.ovpn": newTemplate("files/config.ovpn"),
}
return
}
// newTemplate returns a new template from the assets
func newTemplate(filenames ...string) *template.Template {
f := []string{}
prefix := "/templates"
for _, filename := range filenames {
f = append(f, filepath.Join(prefix, filename))
}
baseTemplate := template.New("base").Funcs(funcs)
tmpl, err := parseAssets(baseTemplate, assets.Assets, f...)
if err != nil {
log.Fatalf("could not parse template: %s", err.Error())
}
return tmpl
}
// parseAssets is a helper function to generate a template from multiple
// assets. If the argument template is nil, it is created from the first
// parameter that is passed (first file).
func parseAssets(t *template.Template, fs http.FileSystem, assets ...string) (*template.Template, error) {
if len(assets) == 0 {
// Not really a problem, but be consistent.
return nil, fmt.Errorf("no templates supplied in call to parseAssets")
}
for _, filename := range assets {
f, err := fs.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
buf := new(bytes.Buffer)
buf.ReadFrom(f)
s := buf.String()
name := filepath.Base(filename)
// First template becomes return value if not already defined,
// and we use that one for subsequent New calls to associate
// all the templates together.
var tmpl *template.Template
if t == nil {
t = template.New(name)
}
if name == t.Name() {
tmpl = t
} else {
tmpl = t.New(name)
}
_, err = tmpl.Parse(s)
if err != nil {
return nil, err
}
}
return t, nil
}

110
views/views.go Normal file
View File

@ -0,0 +1,110 @@
package views
import (
"errors"
"fmt"
"html/template"
"log"
"net/http"
"git.klink.asia/paul/certman/services"
"github.com/gorilla/csrf"
)
type View struct {
Vars map[string]interface{}
Request *http.Request
SessionStore *services.Sessions
}
func New(req *http.Request) *View {
return &View{
Request: req,
Vars: map[string]interface{}{
"CSRF_TOKEN": csrf.Token(req),
"csrfField": csrf.TemplateField(req),
"Meta": map[string]interface{}{
"Path": req.URL.Path,
"Env": "develop",
},
"flashes": []services.Flash{},
"username": "",
},
}
}
func NewWithSession(req *http.Request, sessionStore *services.Sessions) *View {
return &View{
Request: req,
SessionStore: sessionStore,
Vars: map[string]interface{}{
"CSRF_TOKEN": csrf.Token(req),
"csrfField": csrf.TemplateField(req),
"Meta": map[string]interface{}{
"Path": req.URL.Path,
"Env": "develop",
},
"flashes": []services.Flash{},
"username": sessionStore.GetUsername(req),
},
}
}
func (view View) Render(w http.ResponseWriter, name string) {
var err error
t, err := GetTemplate(name)
if err != nil {
log.Printf("the template '%s' does not exist.", name)
view.RenderError(w, 404)
return
}
if view.SessionStore != nil {
// add flashes to template
view.Vars["flashes"] = view.SessionStore.Flashes(w, view.Request)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
t.Execute(w, view.Vars)
}
func (view View) RenderError(w http.ResponseWriter, status int) {
var name string
switch status {
case http.StatusUnauthorized:
name = "401"
case http.StatusForbidden:
name = "403"
case http.StatusNotFound:
name = "404"
default:
name = "500"
}
t, err := GetTemplate(name)
if err != nil {
log.Printf("the error template '%s' does not exist.", name)
fmt.Fprintf(w, "Error page for status '%d' could not be rendered.", status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
t.Execute(w, view.Vars)
}
// GetTemplate returns a parsed template. The template ,ap needs to be
// Initialized by calling `LoadTemplates()` first.
func GetTemplate(name string) (*template.Template, error) {
if tmpl, ok := templates[name]; ok {
return tmpl, nil
}
return nil, errors.New("Template not found")
}