feat: install code of 3 to 24 letters and digits with a button to make a new one; guesses are limited per address

This commit is contained in:
redtea committed 2026-10-14 21:07:36 +03:00
1 parent 47f1b2e55b
commit 29353e5e6a
4 files changed
+69 -10

No files matched your search

+30 -9
View File
@@ -14,6 +14,7 @@ const (
cfgEnrollSecret = "enroll_secret"
cfgSetupDone = "setup_done"
cfgInstallCode = "install_code"
cfgInstallLen = "install_code_len"
cfgUfwEnabled = "ufw_enabled"
cfgTunnelHost = "tunnel_host"
cfgTunnelPort = "tunnel_port"
@@ -52,7 +53,7 @@ func (db *DB) ensureDefaults() error {
return err
}
if code == "" {
if err := db.SetConfig(cfgInstallCode, shortCode()); err != nil {
if err := db.SetConfig(cfgInstallCode, shortCode(db.InstallCodeLen())); err != nil {
return err
}
}
@@ -125,16 +126,36 @@ func randToken() string {
return hex.EncodeToString(b)
}
// shortCode returns a short, URL-safe, typo-resistant code for install links.
// It avoids ambiguous characters (0/O, 1/l/I) so it is easy to read and type.
func shortCode() string {
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
b := make([]byte, 7)
// Install code length: 3 by default, up to 24.
const (
installCodeMin = 3
installCodeMax = 24
installCodeDef = 3
)
// InstallCodeLen is how many characters a new install code has.
func (db *DB) InstallCodeLen() int {
v, _ := db.GetConfig(cfgInstallLen)
if n, err := strconv.Atoi(v); err == nil && n >= installCodeMin && n <= installCodeMax {
return n
}
return installCodeDef
}
// shortCode returns a random code of n English letters and digits for the
// /i/<code> install link. Every character is equally likely.
func shortCode(n int) string {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
out := make([]byte, 0, n)
b := make([]byte, 1)
for len(out) < n {
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
// 248 = 4 * 62: drop the rest, so no character comes up more often.
if b[0] < 248 {
out = append(out, alphabet[int(b[0])%len(alphabet)])
}
return string(b)
}
return string(out)
}
+25 -1
View File
@@ -410,6 +410,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"Admin": s.currentAdmin(r),
"Host": host,
"Secret": secret,
"CodeLen": s.db.InstallCodeLen(),
"Admins": admins,
"KeyCounts": keyCounts,
"PassOff": passOff,
@@ -516,7 +517,22 @@ func (s *Server) handleRotateSecret(w http.ResponseWriter, r *http.Request) {
}
_ = s.db.SetConfig(cfgEnrollSecret, randToken())
// Rotate the short install code too so old /i/<code> links stop working.
_ = s.db.SetConfig(cfgInstallCode, shortCode())
_ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen()))
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
// POST /settings/install/code - a new install code, of the length given:
// the old /i/<code> link stops working.
func (s *Server) handleInstallCode(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
if n, err := strconv.Atoi(r.FormValue("len")); err == nil {
n = min(installCodeMax, max(installCodeMin, n))
_ = s.db.SetConfig(cfgInstallLen, strconv.Itoa(n))
}
_ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen()))
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
@@ -603,9 +619,17 @@ func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
//
// wget -qO- https://host/i/<code> | sudo sh
func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) {
// A short code can be guessed: wrong ones count against the address
// like wrong passwords do.
ip := clientIP(r)
if s.limiter.blocked(ip) {
http.Error(w, "too many attempts", http.StatusTooManyRequests)
return
}
code := r.PathValue("code")
want := s.db.InstallCode()
if want == "" || !constantTimeEqual(code, want) {
s.limiter.fin(ip, false)
http.NotFound(w, r)
return
}
+1
View File
@@ -232,6 +232,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
mux.Handle("POST /settings/tunnel", s.requireAdmin(s.handleSetTunnel))
mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret))
mux.Handle("POST /settings/install/code", s.requireAdmin(s.handleInstallCode))
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
mux.Handle("POST /settings/admins/keys/reset", s.requireAdmin(s.handleResetAdminKeys))
@@ -12,6 +12,10 @@
.copybox .copy { position:absolute; top:7px; right:7px; width:30px; height:30px; padding:0;
display:inline-flex; align-items:center; justify-content:center; background:var(--panel); }
.copybox .copy.done { color:var(--ok); border-color:var(--ok); }
.codeform { display:flex; flex-wrap:wrap; align-items:center; gap:8px 10px; margin-top:10px; font-size:13px; }
.codeform label { margin:0; }
.codeform input[type=number] { width:72px; height:30px; padding:4px 8px; }
.codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; }
</style>
</head>
<body>
@@ -35,6 +39,15 @@
<label>Короткая команда (секрет уже вшит в ссылку)</label>
<div class="copybox"><pre id="short">{{.InstallShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="short" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
<form method="post" action="/settings/install/code" class="codeform"
onsubmit="return confirm('Сделать новый код? Старая короткая ссылка перестанет работать.')">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="codelen">Символов в коде</label>
<input type="number" id="codelen" name="len" min="3" max="24" value="{{.CodeLen}}">
<button type="submit" class="btn ghost small ico"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>Новый код</button>
<span class="muted">латиница и цифры, от 3 до 24; новый код отменяет старую ссылку</span>
</form>
<details style="margin-top:10px">
<summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary>
<label>curl</label>