All checks were successful
release-tag / release-image (push) Successful in 1m35s
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
733 lines
19 KiB
Go
733 lines
19 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"html/template"
|
||
"io"
|
||
"log"
|
||
"math"
|
||
"math/big"
|
||
"net/http"
|
||
"os"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
var (
|
||
lower = "abcdefghijklmnopqrstuvwxyz"
|
||
upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||
digits = "0123456789"
|
||
symbols = "!@#$%^&*()-=+;:,.?" + "|" // escaped backslash
|
||
_ = regexp.MustCompile(`[O0Il1|]+`) // reserved for future validation
|
||
)
|
||
|
||
// ==== Options & ENV parsing ====
|
||
|
||
type options struct {
|
||
length int
|
||
minLower int
|
||
minUpper int
|
||
minDigits int
|
||
minSymbols int
|
||
custom string
|
||
exclude string
|
||
noAmbig bool
|
||
noSeq bool
|
||
noRepeat bool
|
||
unique bool
|
||
template string
|
||
symbolSet string
|
||
count int
|
||
jsonOut bool
|
||
|
||
// Virtual Clipboard target
|
||
clipBase string // e.g. http://localhost:8080
|
||
clipRoom string // e.g. default
|
||
clipAuthor string // optional author field
|
||
clipType string // usually "text"
|
||
clipToken string // X-Token value (falls back to CLIPBOARD_TOKEN)
|
||
|
||
// legacy/custom sender (kept for compatibility)
|
||
sendURL string
|
||
sendField string
|
||
sendHeaders []string
|
||
sendMethod string
|
||
dryRun bool
|
||
timeout time.Duration
|
||
|
||
// web ui
|
||
webEnabled bool
|
||
webAddr string
|
||
webUser string
|
||
webPass string
|
||
}
|
||
|
||
func getenvInt(key string, def int) int {
|
||
if v := os.Getenv(key); v != "" {
|
||
if i, err := strconv.Atoi(v); err == nil {
|
||
return i
|
||
}
|
||
}
|
||
return def
|
||
}
|
||
|
||
func getenvBool(key string, def bool) bool {
|
||
if v := os.Getenv(key); v != "" {
|
||
s := strings.ToLower(strings.TrimSpace(v))
|
||
if s == "1" || s == "true" || s == "yes" || s == "on" {
|
||
return true
|
||
}
|
||
if s == "0" || s == "false" || s == "no" || s == "off" {
|
||
return false
|
||
}
|
||
}
|
||
return def
|
||
}
|
||
|
||
func getenvStr(key, def string) string {
|
||
if v := os.Getenv(key); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
func getenvDuration(key string, def time.Duration) time.Duration {
|
||
if v := os.Getenv(key); v != "" {
|
||
if d, err := time.ParseDuration(v); err == nil {
|
||
return d
|
||
}
|
||
}
|
||
return def
|
||
}
|
||
|
||
func parseEnv() options {
|
||
var opt options
|
||
opt.length = getenvInt("PWGEN_LENGTH", 20)
|
||
opt.minLower = getenvInt("PWGEN_MIN_LOWER", 1)
|
||
opt.minUpper = getenvInt("PWGEN_MIN_UPPER", 1)
|
||
opt.minDigits = getenvInt("PWGEN_MIN_DIGITS", 1)
|
||
opt.minSymbols = getenvInt("PWGEN_MIN_SYMBOLS", 1)
|
||
opt.custom = getenvStr("PWGEN_CHARSET", "")
|
||
opt.exclude = getenvStr("PWGEN_EXCLUDE", "")
|
||
opt.noAmbig = getenvBool("PWGEN_NO_AMBIGUOUS", true)
|
||
opt.noSeq = getenvBool("PWGEN_NO_SEQ", true)
|
||
opt.noRepeat = getenvBool("PWGEN_NO_REPEAT", true)
|
||
opt.unique = getenvBool("PWGEN_UNIQUE", false)
|
||
opt.template = getenvStr("PWGEN_TEMPLATE", "")
|
||
opt.symbolSet = getenvStr("PWGEN_SYMBOLS", symbols)
|
||
opt.count = getenvInt("PWGEN_COUNT", 1)
|
||
opt.jsonOut = getenvBool("PWGEN_JSON", false)
|
||
|
||
// Virtual Clipboard integration
|
||
opt.clipBase = getenvStr("PWGEN_CLIPBOARD_BASE", "http://localhost:8080")
|
||
opt.clipRoom = getenvStr("PWGEN_CLIPBOARD_ROOM", "default")
|
||
opt.clipAuthor = getenvStr("PWGEN_CLIPBOARD_AUTHOR", "")
|
||
opt.clipType = getenvStr("PWGEN_CLIPBOARD_TYPE", "text")
|
||
opt.clipToken = getenvStr("PWGEN_CLIPBOARD_TOKEN", "")
|
||
if opt.clipToken == "" {
|
||
opt.clipToken = os.Getenv("CLIPBOARD_TOKEN")
|
||
}
|
||
|
||
// legacy/custom sender (still supported)
|
||
opt.sendURL = getenvStr("PWGEN_SEND_URL", "")
|
||
opt.sendField = getenvStr("PWGEN_SEND_FIELD", "content")
|
||
if h := getenvStr("PWGEN_SEND_HEADERS", ""); h != "" {
|
||
opt.sendHeaders = strings.Split(h, ";")
|
||
}
|
||
opt.sendMethod = getenvStr("PWGEN_SEND_METHOD", "POST")
|
||
opt.dryRun = getenvBool("PWGEN_DRY_RUN", false)
|
||
opt.timeout = getenvDuration("PWGEN_TIMEOUT", 10*time.Second)
|
||
|
||
opt.webEnabled = getenvBool("PWGEN_WEB", true)
|
||
// Use a different default port than the VC server to avoid conflicts
|
||
opt.webAddr = getenvStr("PWGEN_WEB_ADDR", ":8080")
|
||
opt.webUser = getenvStr("PWGEN_WEB_USER", "")
|
||
opt.webPass = getenvStr("PWGEN_WEB_PASS", "")
|
||
return opt
|
||
}
|
||
|
||
// ==== Core generation logic ====
|
||
|
||
// randInt returns a crypto-strong uniform integer in [0, n)
|
||
func randInt(n int64) (int64, error) {
|
||
if n <= 0 {
|
||
return 0, errors.New("invalid n")
|
||
}
|
||
max := big.NewInt(n)
|
||
x, err := rand.Int(rand.Reader, max)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return x.Int64(), nil
|
||
}
|
||
|
||
func shuffleBytes(b []byte) error {
|
||
for i := len(b) - 1; i > 0; i-- {
|
||
j64, err := randInt(int64(i + 1))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
j := int(j64)
|
||
b[i], b[j] = b[j], b[i]
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func containsRun(s string, maxRun int) bool {
|
||
if maxRun <= 1 { // no runs allowed
|
||
for i := 1; i < len(s); i++ {
|
||
if s[i] == s[i-1] {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
run := 1
|
||
for i := 1; i < len(s); i++ {
|
||
if s[i] == s[i-1] {
|
||
run++
|
||
if run > maxRun {
|
||
return true
|
||
}
|
||
} else {
|
||
run = 1
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasSeq(s string, window int) bool {
|
||
if window <= 1 || len(s) < window {
|
||
return false
|
||
}
|
||
for i := 0; i <= len(s)-window; i++ {
|
||
asc := true
|
||
for j := 1; j < window; j++ {
|
||
if s[i+j] != s[i+j-1]+1 {
|
||
asc = false
|
||
break
|
||
}
|
||
}
|
||
if asc {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func removeChars(set, exclude string) string {
|
||
m := make(map[rune]bool)
|
||
for _, r := range exclude {
|
||
m[r] = true
|
||
}
|
||
var out strings.Builder
|
||
for _, r := range set {
|
||
if !m[r] {
|
||
out.WriteRune(r)
|
||
}
|
||
}
|
||
return out.String()
|
||
}
|
||
|
||
func buildSets(opt options) (lowerSet, upperSet, digitSet, symbolSet, anySet string) {
|
||
ls, us, ds, ss := lower, upper, digits, opt.symbolSet
|
||
if opt.noAmbig {
|
||
ls = removeChars(ls, "l")
|
||
us = removeChars(us, "OI")
|
||
ds = removeChars(ds, "01")
|
||
ss = removeChars(ss, "|")
|
||
}
|
||
if opt.exclude != "" {
|
||
ls = removeChars(ls, opt.exclude)
|
||
us = removeChars(us, opt.exclude)
|
||
ds = removeChars(ds, opt.exclude)
|
||
ss = removeChars(ss, opt.exclude)
|
||
}
|
||
any := uniqueConcat(ls + us + ds + ss + opt.custom)
|
||
return ls, us, ds, ss, any
|
||
}
|
||
|
||
func uniqueConcat(s string) string {
|
||
m := map[rune]bool{}
|
||
var b strings.Builder
|
||
for _, r := range s {
|
||
if !m[r] {
|
||
m[r] = true
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func pickRandom(set string) (byte, error) {
|
||
if len(set) == 0 {
|
||
return 0, errors.New("empty character set after exclusions")
|
||
}
|
||
i, err := randInt(int64(len(set)))
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return set[i], nil
|
||
}
|
||
|
||
func generateOne(opt options, sets [5]string) (string, error) {
|
||
ls, us, ds, ss, anyPool := sets[0], sets[1], sets[2], sets[3], sets[4]
|
||
if opt.template != "" {
|
||
return generateFromTemplate(opt, ls, us, ds, ss)
|
||
}
|
||
if opt.length <= 0 {
|
||
return "", errors.New("length must be > 0")
|
||
}
|
||
if opt.minLower+opt.minUpper+opt.minDigits+opt.minSymbols > opt.length {
|
||
return "", errors.New("sum of minimums exceeds length")
|
||
}
|
||
bytesBuf := make([]byte, 0, opt.length)
|
||
for i := 0; i < opt.minLower; i++ {
|
||
c, err := pickRandom(ls)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
bytesBuf = append(bytesBuf, c)
|
||
}
|
||
for i := 0; i < opt.minUpper; i++ {
|
||
c, err := pickRandom(us)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
bytesBuf = append(bytesBuf, c)
|
||
}
|
||
for i := 0; i < opt.minDigits; i++ {
|
||
c, err := pickRandom(ds)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
bytesBuf = append(bytesBuf, c)
|
||
}
|
||
for i := 0; i < opt.minSymbols; i++ {
|
||
c, err := pickRandom(ss)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
bytesBuf = append(bytesBuf, c)
|
||
}
|
||
pool := anyPool
|
||
if opt.unique && len(pool) < opt.length {
|
||
return "", fmt.Errorf("unique requested but pool size %d < length %d", len(pool), opt.length)
|
||
}
|
||
for len(bytesBuf) < opt.length {
|
||
c, err := pickRandom(pool)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if opt.unique && bytesContains(bytesBuf, c) {
|
||
continue
|
||
}
|
||
bytesBuf = append(bytesBuf, c)
|
||
}
|
||
if err := shuffleBytes(bytesBuf); err != nil {
|
||
return "", err
|
||
}
|
||
pwd := string(bytesBuf)
|
||
if opt.noRepeat && containsRun(pwd, 1) {
|
||
return generateOne(opt, sets)
|
||
}
|
||
if opt.noSeq && hasSeq(pwd, 3) {
|
||
return generateOne(opt, sets)
|
||
}
|
||
return pwd, nil
|
||
}
|
||
|
||
func generateFromTemplate(opt options, ls, us, ds, ss string) (string, error) {
|
||
var out strings.Builder
|
||
for i := 0; i < len(opt.template); i++ {
|
||
ch := opt.template[i]
|
||
var set string
|
||
switch ch {
|
||
case 'l':
|
||
set = ls
|
||
case 'L':
|
||
set = us
|
||
case 'd':
|
||
set = ds
|
||
case 's':
|
||
set = ss
|
||
case 'a':
|
||
set = uniqueConcat(ls + us + ds)
|
||
case 'A':
|
||
set = uniqueConcat(ls + us + ds + ss)
|
||
default:
|
||
out.WriteByte(ch)
|
||
continue
|
||
}
|
||
c, err := pickRandom(set)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
out.WriteByte(c)
|
||
}
|
||
pwd := out.String()
|
||
if opt.noRepeat && containsRun(pwd, 1) {
|
||
return generateFromTemplate(opt, ls, us, ds, ss)
|
||
}
|
||
if opt.noSeq && hasSeq(pwd, 3) {
|
||
return generateFromTemplate(opt, ls, us, ds, ss)
|
||
}
|
||
return pwd, nil
|
||
}
|
||
|
||
func bytesContains(b []byte, c byte) bool {
|
||
for _, x := range b {
|
||
if x == c {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// entropy helpers
|
||
func entropyForTemplate(tpl string, ls, us, ds, ss string) float64 {
|
||
bits := 0.0
|
||
for i := 0; i < len(tpl); i++ {
|
||
ch := tpl[i]
|
||
size := 1
|
||
switch ch {
|
||
case 'l':
|
||
size = len(ls)
|
||
case 'L':
|
||
size = len(us)
|
||
case 'd':
|
||
size = len(ds)
|
||
case 's':
|
||
size = len(ss)
|
||
case 'a':
|
||
size = len(ls) + len(us) + len(ds)
|
||
case 'A':
|
||
size = len(ls) + len(us) + len(ds) + len(ss)
|
||
default:
|
||
size = 1
|
||
}
|
||
bits += math.Log2(float64(size))
|
||
}
|
||
return bits
|
||
}
|
||
|
||
func entropyForCombined(length int, poolSize int) float64 {
|
||
return float64(length) * math.Log2(float64(poolSize))
|
||
}
|
||
|
||
// sendToClipboard posts to Virtual Clipboard (preferred) or legacy custom endpoint
|
||
func sendToClipboard(opt options, pwd string) (bool, error) {
|
||
// Preferred: Virtual Clipboard
|
||
if opt.clipBase != "" && opt.clipRoom != "" {
|
||
url := strings.TrimRight(opt.clipBase, "/") + "/api/" + opt.clipRoom + "/clip"
|
||
payload := map[string]string{
|
||
"type": opt.clipType,
|
||
"content": pwd,
|
||
}
|
||
if opt.clipAuthor != "" {
|
||
payload["author"] = opt.clipAuthor
|
||
}
|
||
body, _ := json.Marshal(payload)
|
||
if opt.dryRun {
|
||
log.Printf("[dry-run] VC POST %s -> %s", url, string(body))
|
||
return false, nil
|
||
}
|
||
client := &http.Client{Timeout: opt.timeout}
|
||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if tok := opt.clipToken; tok != "" {
|
||
req.Header.Set("X-Token", tok)
|
||
}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
defer resp.Body.Close()
|
||
io.ReadAll(resp.Body)
|
||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||
return true, nil
|
||
}
|
||
return false, fmt.Errorf("clipboard API responded %s", resp.Status)
|
||
}
|
||
|
||
// Fallback: legacy/custom
|
||
if opt.sendURL == "" {
|
||
return false, nil
|
||
}
|
||
method := strings.ToUpper(opt.sendMethod)
|
||
if method == "" {
|
||
method = "POST"
|
||
}
|
||
payload := map[string]string{opt.sendField: pwd}
|
||
body, _ := json.Marshal(payload)
|
||
if opt.dryRun {
|
||
log.Printf("[dry-run] Custom %s %s -> %s (headers=%v)", method, opt.sendURL, string(body), opt.sendHeaders)
|
||
return false, nil
|
||
}
|
||
client := &http.Client{Timeout: opt.timeout}
|
||
req, err := http.NewRequest(method, opt.sendURL, bytes.NewReader(body))
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
for _, kv := range opt.sendHeaders {
|
||
parts := strings.SplitN(kv, ":", 2)
|
||
if len(parts) == 2 {
|
||
req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
|
||
}
|
||
}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
defer resp.Body.Close()
|
||
io.ReadAll(resp.Body)
|
||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||
return true, nil
|
||
}
|
||
return false, fmt.Errorf("clipboard API responded %s", resp.Status)
|
||
}
|
||
|
||
// ==== Web UI ====
|
||
|
||
var page = template.Must(template.New("idx").Parse(`<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Passwort-Generator</title>
|
||
<style>
|
||
:root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; }
|
||
.container { max-width: 720px; margin: 5vh auto; padding: 24px; border-radius: 16px; box-shadow: 0 8px 30px rgba(0,0,0,.08); }
|
||
.h1 { font-size: 1.5rem; margin-bottom: 8px; }
|
||
.sub { color: #555; margin-bottom: 16px; }
|
||
.btn { display: inline-flex; align-items:center; gap:10px; padding: 12px 16px; border:0; border-radius: 12px; cursor:pointer; font-weight: 600; }
|
||
.btn-primary { background: #111; color:#fff; }
|
||
.row { display:flex; gap:12px; align-items:center; }
|
||
.out { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; padding: 10px 12px; border:1px solid #e3e3e3; border-radius: 10px; flex:1; overflow:auto; white-space:pre-wrap; }
|
||
.badge { display:inline-block; padding:.25rem .5rem; border-radius:999px; background:#f2f2f2; }
|
||
.footer { margin-top:16px; color:#666; font-size:.9rem; }
|
||
label { font-size:.9rem; color:#444; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="h1">🔐 Passwort-Generator</div>
|
||
<div class="sub">Ein Klick – generieren & an den <em>Virtual Clipboard</em>-Server posten.</div>
|
||
<div class="row" style="margin-bottom:12px;">
|
||
<label>Count <input id="count" type="number" min="1" max="20" value="1" style="width:90px; padding:8px; border-radius:10px; border:1px solid #e3e3e3" /></label>
|
||
<button id="go" class="btn btn-primary">Passwort generieren</button>
|
||
<button id="copy" class="btn" title="in die lokale Browser-Zwischenablage kopieren">Kopieren</button>
|
||
</div>
|
||
<div class="out" id="out" placeholder="Hier erscheint das Passwort…"></div>
|
||
<div style="margin-top:8px; display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
|
||
<span class="badge" id="entropy">Entropy: –</span>
|
||
<span class="badge" id="sent">API: –</span>
|
||
<span class="badge" id="room">Room: {{.Room}}</span>
|
||
<span class="badge" id="base">Base: {{.Base}}</span>
|
||
</div>
|
||
<div class="footer">Server POST: <code>{{.Base}}/api/{{.Room}}/clip</code> · Steuerung per <code>PWGEN_*</code>-ENV.</div>
|
||
</div>
|
||
<script>
|
||
async function gen(count){
|
||
const res = await fetch('/api/generate?count='+encodeURIComponent(count||1), {method:'POST', headers:{'X-Requested-With':'fetch'}});
|
||
if(!res.ok){ const t = await res.text(); throw new Error('HTTP '+res.status+' '+t); }
|
||
return res.json();
|
||
}
|
||
const go = document.getElementById('go');
|
||
const out = document.getElementById('out');
|
||
const ent = document.getElementById('entropy');
|
||
const sent = document.getElementById('sent');
|
||
const cnt = document.getElementById('count');
|
||
const copy = document.getElementById('copy');
|
||
|
||
go.addEventListener('click', async ()=>{
|
||
go.disabled = true; go.textContent='…';
|
||
try{
|
||
const data = await gen(cnt.value);
|
||
if(Array.isArray(data) && data.length){
|
||
out.textContent = data.map(r=>r.password).join('\n');
|
||
const median = [...data.map(r=>r.entropy_bits)].sort((a,b)=>a-b)[Math.floor(data.length/2)];
|
||
ent.textContent = 'Entropy: '+median.toFixed(1)+' bits';
|
||
sent.textContent = 'API: '+(data.some(r=>r.sent)?'gesendet':'nicht gesendet');
|
||
}
|
||
}catch(e){ out.textContent = 'Fehler: '+e.message; }
|
||
finally{ go.disabled=false; go.textContent='Passwort generieren'; }
|
||
});
|
||
|
||
copy.addEventListener('click', async ()=>{
|
||
try{ await navigator.clipboard.writeText(out.textContent); sent.textContent='API: lokal kopiert'; }catch(e){ alert('Kopieren fehlgeschlagen: '+e.message); }
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>`))
|
||
|
||
func basicAuth(user, pass string, next http.Handler) http.Handler {
|
||
if user == "" && pass == "" {
|
||
return next
|
||
}
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
u, p, ok := r.BasicAuth()
|
||
if !ok || u != user || p != pass {
|
||
w.Header().Set("WWW-Authenticate", "Basic realm=restricted")
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte("Unauthorized"))
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func withSecurityHeaders(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
w.Header().Set("X-Frame-Options", "DENY")
|
||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func startWeb(opt options) error {
|
||
ls, us, ds, ss, anyPool := buildSets(opt)
|
||
sets := [5]string{ls, us, ds, ss, anyPool}
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_ = page.Execute(w, map[string]string{"Room": opt.clipRoom, "Base": opt.clipBase})
|
||
})
|
||
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
count := 1
|
||
if c := r.URL.Query().Get("count"); c != "" {
|
||
if n, err := strconv.Atoi(c); err == nil && n > 0 && n <= 50 {
|
||
count = n
|
||
}
|
||
}
|
||
res := make([]map[string]interface{}, 0, count)
|
||
for i := 0; i < count; i++ {
|
||
pwd, err := generateOne(opt, sets)
|
||
if err != nil {
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
_, _ = w.Write([]byte(err.Error()))
|
||
return
|
||
}
|
||
var bits float64
|
||
if opt.template != "" {
|
||
bits = entropyForTemplate(opt.template, ls, us, ds, ss)
|
||
} else {
|
||
bits = entropyForCombined(len(pwd), len(anyPool))
|
||
}
|
||
sent, err := sendToClipboard(opt, pwd)
|
||
if err != nil {
|
||
log.Printf("send error: %v", err)
|
||
}
|
||
res = append(res, map[string]interface{}{
|
||
"password": pwd,
|
||
"entropy_bits": bits,
|
||
"sent": sent,
|
||
"send_to": strings.TrimRight(opt.clipBase, "/") + "/api/" + opt.clipRoom + "/clip",
|
||
})
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(res)
|
||
})
|
||
|
||
h := withSecurityHeaders(basicAuth(opt.webUser, opt.webPass, mux))
|
||
log.Printf("Web UI listening on %s", opt.webAddr)
|
||
return http.ListenAndServe(opt.webAddr, h)
|
||
}
|
||
|
||
// ==== CLI ====
|
||
|
||
type result struct {
|
||
Password string `json:"password"`
|
||
Entropy float64 `json:"entropy_bits"`
|
||
Sent bool `json:"sent"`
|
||
SendTo string `json:"send_to,omitempty"`
|
||
}
|
||
|
||
func runCLI(opt options) int {
|
||
ls, us, ds, ss, anyPool := buildSets(opt)
|
||
sets := [5]string{ls, us, ds, ss, anyPool}
|
||
results := make([]result, 0, opt.count)
|
||
|
||
// Resolve effective target URL for reporting
|
||
sendTo := ""
|
||
if opt.clipBase != "" && opt.clipRoom != "" {
|
||
sendTo = strings.TrimRight(opt.clipBase, "/") + "/api/" + opt.clipRoom + "/clip"
|
||
} else if opt.sendURL != "" {
|
||
sendTo = opt.sendURL
|
||
}
|
||
|
||
for i := 0; i < opt.count; i++ {
|
||
pwd, err := generateOne(opt, sets)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "error:", err)
|
||
return 1
|
||
}
|
||
var bits float64
|
||
if opt.template != "" {
|
||
bits = entropyForTemplate(opt.template, ls, us, ds, ss)
|
||
} else {
|
||
bits = entropyForCombined(len(pwd), len(anyPool))
|
||
}
|
||
sent, err := sendToClipboard(opt, pwd)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "send error:", err)
|
||
}
|
||
results = append(results, result{Password: pwd, Entropy: bits, Sent: sent, SendTo: sendTo})
|
||
}
|
||
|
||
if opt.jsonOut {
|
||
enc := json.NewEncoder(os.Stdout)
|
||
enc.SetIndent("", " ")
|
||
_ = enc.Encode(results)
|
||
return 0
|
||
}
|
||
|
||
if opt.count == 1 {
|
||
fmt.Println(results[0].Password)
|
||
fmt.Fprintf(os.Stderr, "entropy≈%.1f bits\n", results[0].Entropy)
|
||
} else {
|
||
for idx, r := range results {
|
||
fmt.Printf("%2d: %s\n", idx+1, r.Password)
|
||
}
|
||
vals := make([]float64, 0, len(results))
|
||
for _, r := range results {
|
||
vals = append(vals, r.Entropy)
|
||
}
|
||
sort.Float64s(vals)
|
||
fmt.Fprintf(os.Stderr, "entropy median≈%.1f bits\n", vals[len(vals)/2])
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// ==== main ====
|
||
|
||
func main() {
|
||
opt := parseEnv()
|
||
if opt.webEnabled {
|
||
if err := startWeb(opt); err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
return
|
||
}
|
||
os.Exit(runCLI(opt))
|
||
}
|