Browse Source

Initial commit

master
Tyler Sommer 2 years ago
commit
b648598bba
Signed by: tyler-sommer GPG Key ID: C09C010500DBD008
5 changed files with 429 additions and 0 deletions
  1. +21
    -0
      LICENSE
  2. +66
    -0
      README.md
  3. +160
    -0
      cmd/eokvin/http.go
  4. +89
    -0
      cmd/eokvin/main.go
  5. +93
    -0
      cmd/eokvin/store.go

+ 21
- 0
LICENSE View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019 Tyler Sommer <contact@tylersommer.pro>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

+ 66
- 0
README.md View File

@ -0,0 +1,66 @@
eok.vin
=======
Private, single-user, self-hosted URL shortening service.
> "These are words." -- anonymous
Usage
-----
eokvin enforces TLS, relying on LetsEncrypt to issue certificates
automatically by default. Short URLs are created using a basic POST API
and expire after a set time.
```
Usage of ./eokvin:
-cert-file string
TLS certificate chain file, blank for autocert
-hash-token string
If given, the sha256 of the given value will be printed
-host string
Listen hostname (default "eok.vin")
-http-port int
HTTP listen port (default 80)
-key-file string
TLS private key file, blank for autocert
-port int
HTTPS listen port (default 443)
-token string
SHA256 of the secret token, used to authenticate
```
### Important notes
* A secret token is required to create new short URLs, given in the `-token`
command-line option. This must be the hexadecimal string representation of
the token (ie. 64 characters long) in question.
* Use the `-hash-token` command-line option to print the SHA256 result
(formatted as the `-hash` option expects) of a given phrase.
```
$ ./eokvin -hash-token "this is a really long and arbitrary input"
d1e6d468c926d9167693c190688d964fec0258c4ef4a4e1ed9cd87ea9c682156
```
* To avoid using LetsEncrypt (for example, when running locally), use a
standard cert and key by using the `-cert-file` and `-key-file`
command-line options.
### Create a short URL
Submit a POST request with Content-Type `application/x-www-form-encoded`
to `/new` with the request body containing:
1. The original, plaintext secret token as `token=<plain token>`.
2. The real URL to redirect to for the new short URL as `url=<redirect-url>`.
Upon success, the server will respond with a JSON blob. For example:
```json
{"short-url":"https://localhost:3000/yuzahnt5"}
```
Visiting the generated URL in your browser will redirect you to the specified
redirect URL. After the configured expiry duration passes (5 minutes at the
time of writing), short URLs are no longer accessible and are removed from the
in-memory data store.

+ 160
- 0
cmd/eokvin/http.go View File

@ -0,0 +1,160 @@
package main
import (
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/json"
"fmt"
"golang.org/x/crypto/acme/autocert"
"log"
"net/http"
"strings"
)
func listenAndServe() error {
l := fmt.Sprintf("%s:%d", listenHost, listenPortHTTP)
srv := &http.Server{Addr: l, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectToCanonicalHost(w, r)
return
})}
return srv.ListenAndServe()
}
func listenAndServeTLS() error {
l := fmt.Sprintf("%s:%d", listenHost, listenPortHTTPS)
srv := &http.Server{Addr: l, Handler: newServeMux()}
if tlsKeyFile == "" && tlsCertFile == "" {
hosts := append([]string{listenHost}, fmt.Sprintf("www.%s", listenHost))
m := autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(hosts...),
Cache: autocert.DirCache("certs"),
}
srv.TLSConfig = &tls.Config{GetCertificate: m.GetCertificate}
}
return srv.ListenAndServeTLS(tlsCertFile, tlsKeyFile)
}
func newServeMux() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("/new",
ensureCanonicalHost(
acceptMethod("POST",
requireToken(newHandler))))
mux.Handle("/", ensureCanonicalHost(indexHandler))
return mux
}
// ensureCanonicalHost decorates a http.Handler, ensuring that the
// request being served is on the canonical hostname.
func ensureCanonicalHost(h http.Handler) http.Handler {
listenHostPort := fmt.Sprintf("%s:%d", listenHost, listenPortHTTPS)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host != listenHost && r.Host != listenHostPort {
log.Println(r.Host, "doesnt equal", listenHost, "or", listenPortHTTPS)
redirectToCanonicalHost(w, r)
return
}
h.ServeHTTP(w, r)
})
}
// acceptMethod decorates a http.Handler, ensuring that the given HTTP method
// is used in the request.
func acceptMethod(m string, h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != m {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
h.ServeHTTP(w, r)
})
}
func verifyToken(s string) bool {
sv := fmt.Sprintf("%x", sha256.Sum256([]byte(s)))
return subtle.ConstantTimeCompare([]byte(sv), []byte(tokenSHA256)) == 1
}
// requireToken decorates a http.Handler, ensuring that the request has a valid
// token identifier.
func requireToken(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !verifyToken(r.PostFormValue("token")) {
w.WriteHeader(http.StatusForbidden)
return
}
h.ServeHTTP(w, r)
})
}
// redirectToCanonicalHost responds with a redirect to the canonical host.
func redirectToCanonicalHost(w http.ResponseWriter, r *http.Request) {
t := canonicalHost + r.URL.Path
http.Redirect(w, r, t, http.StatusMovedPermanently)
if _, err := fmt.Fprintf(w, `<a href="%s">Redirecting...</a>`, t); err != nil {
log.Println("error writing response:", err.Error())
}
}
// newHandler is an http.Handler that creates a new item in the urlStore store.
var newHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
link := r.PostFormValue("url")
if len(link) == 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
k, err := urlStore.newItemID()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
urlStore.mu.Lock()
urlStore.entries[k] = newItem(link)
urlStore.mu.Unlock()
b, err := json.Marshal(map[string]string{"short-url": canonicalHost + k.String()})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, err = fmt.Fprintf(w, `{"error": "%s"}\n`, strings.Replace(err.Error(), `"`, `'`, -1))
if err != nil {
log.Println("error writing response:", err.Error())
}
return
}
w.WriteHeader(http.StatusCreated)
if _, err = fmt.Fprintln(w, string(b)); err != nil {
log.Println("error writing response:", err.Error())
}
return
})
// indexHandler is a catch-all http.Handler that attempts to lookup items in
// the store based on request path.
var indexHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := strings.TrimLeft(r.URL.Path, "/")
if len(key) == 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
k := itemID(key)
urlStore.mu.RLock()
v, ok := urlStore.entries[k]
urlStore.mu.RUnlock()
if ok {
if urlStore.isExpired(v) {
// rely on the reaper function to actually delete items.
w.WriteHeader(http.StatusNotFound)
return
}
u := v.String()
http.Redirect(w, r, u, http.StatusMovedPermanently)
if _, err := fmt.Fprintf(w, `<a href="%s">Redirecting...</a>`, u); err != nil {
log.Println("error writing response:", err.Error())
}
return
}
w.WriteHeader(http.StatusNotFound)
return
})

+ 89
- 0
cmd/eokvin/main.go View File

@ -0,0 +1,89 @@
package main
import (
"crypto/sha256"
"flag"
"fmt"
"log"
"os"
"time"
)
// Runtime configuration values.
var listenHost string
var listenPortHTTPS int
var listenPortHTTP int
var canonicalHost string
var tokenSHA256 string
var tlsKeyFile string
var tlsCertFile string
var rawToken string
// urlTTL contains the time.Duration describing how long a short URL shall
// remain accessible.
const urlTTL = 10 * time.Minute
// urlStore contains URL values that map to short, random string keys.
var urlStore = &store{entries: make(map[itemID]item), ttl: urlTTL}
func init() {
flag.StringVar(&listenHost, "host", "eok.vin", "Listen hostname")
flag.IntVar(&listenPortHTTPS, "port", 443, "HTTPS listen port")
flag.IntVar(&listenPortHTTP, "http-port", 80, "HTTP listen port")
flag.StringVar(&tlsKeyFile, "key-file", "", "TLS private key file, blank for autocert")
flag.StringVar(&tlsCertFile, "cert-file", "", "TLS certificate chain file, blank for autocert")
flag.StringVar(&tokenSHA256, "token", "", "SHA256 of the secret token, used to authenticate")
flag.StringVar(&rawToken, "hash-token", "", "If given, the sha256 of the given value will be printed")
}
func validate() {
flag.Parse()
if len(rawToken) > 0 {
fmt.Printf("%x\n", sha256.Sum256([]byte(rawToken)))
os.Exit(0)
return
}
if len(listenHost) == 0 {
log.Fatal("host cannot be blank")
}
if listenPortHTTPS <= 0 {
log.Fatal("port must be > 0")
}
if listenPortHTTP <= 0 {
log.Fatal("http-port must be > 0")
}
if len(tokenSHA256) != 64 {
log.Fatal("token must be a valid sha256 sum")
}
canonicalHost = fmt.Sprintf("https://%s:%d/", listenHost, listenPortHTTPS)
}
func main() {
validate()
// Launch the HTTP redirect server.
go func() {
if err := listenAndServe(); err != nil {
log.Println(err.Error())
}
}()
// Launch the HTTPS main server.
go func() {
if err := listenAndServeTLS(); err != nil {
log.Println(err.Error())
}
}()
// Launch the expired entry reaper.
go func() {
if err := urlStore.expiredItemReaper(); err != nil {
log.Println(err.Error())
}
}()
// Yield forever.
select {}
}

+ 93
- 0
cmd/eokvin/store.go View File

@ -0,0 +1,93 @@
package main
import (
"crypto/rand"
"errors"
"math/big"
"sync"
"time"
)
// randSet is the set of characters used when generating a random key.
const randSet = "abcdefghijklmnopqrstuvwxyz1234567890"
const randSetLen = int64(len(randSet))
// An itemID is a unique key in a store.
type itemID string
func (c itemID) String() string {
return string(c)
}
// An item is a single item, stored in a store.
type item struct {
value string
insertedAt time.Time
}
func (c item) String() string {
return c.value
}
// A store is an in-memory data store with expiring items.
type store struct {
mu sync.RWMutex
entries map[itemID]item
ttl time.Duration
}
// newItemID creates a new store key, ensuring it is unique.
func (cch *store) newItemID() (itemID, error) {
b := make([]byte, 8)
l := big.NewInt(randSetLen)
for i := 0; i < 8; i++ {
n, err := rand.Int(rand.Reader, l)
if err != nil {
return "", err
}
b[i] = randSet[int(n.Int64())]
}
k := itemID(b)
cch.mu.RLock()
defer cch.mu.RUnlock()
// Avoid overwriting existing entries
if _, ok := cch.entries[k]; ok {
return "", errors.New("cache: collision detected")
}
return k, nil
}
// isExpired returns true if the given item is expired.
func (cch *store) isExpired(c item) bool {
return c.insertedAt.Before(time.Now().Add(-1 * cch.ttl))
}
// expiredItemReaper deletes expired entries from the store at regular
// intervals.
func (cch *store) expiredItemReaper() error {
for {
select {
case <-time.After(30 * time.Second):
var del []itemID
cch.mu.RLock()
for k, v := range cch.entries {
if cch.isExpired(v) {
del = append(del, k)
}
}
cch.mu.RUnlock()
if len(del) == 0 {
continue
}
cch.mu.Lock()
for _, k := range del {
delete(cch.entries, k)
}
cch.mu.Unlock()
}
}
}
// newItem initializes a new item.
func newItem(s string) item {
return item{value: s, insertedAt: time.Now()}
}

Loading…
Cancel
Save