fierj
PublicTiny personal git forge
ab5d29f62e6d420288b8d0e1681607f80c261745
diff --git a/auth.go b/auth.go
index be0c89d..a4bcf2a 100644
--- a/auth.go
+++ b/auth.go
@@ -13,21 +13,37 @@ import (
"golang.org/x/crypto/bcrypt"
)
-// UserStore holds username → bcrypt-hash mappings loaded from a JSON file.
-type UserStore map[string]string
+// UserInfo holds a user's credentials and SSH keys.
+type UserInfo struct {
+ Password string `json:"password"`
+ SSHKeys []string `json:"ssh_keys,omitempty"`
+}
+
+// UserStore holds username → UserInfo mappings.
+type UserStore map[string]UserInfo
func LoadUsers(path string) (UserStore, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
- return UserStore{}, nil // first run, no users yet
+ return UserStore{}, nil
}
return nil, err
}
+ // Try new UserInfo format first.
var store UserStore
- if err := json.Unmarshal(data, &store); err != nil {
+ if err := json.Unmarshal(data, &store); err == nil {
+ return store, nil
+ }
+ // Fall back to legacy format (map[string]string) and migrate.
+ var legacy map[string]string
+ if err := json.Unmarshal(data, &legacy); err != nil {
return nil, err
}
+ store = make(UserStore, len(legacy))
+ for user, hash := range legacy {
+ store[user] = UserInfo{Password: hash}
+ }
return store, nil
}
@@ -35,11 +51,11 @@ func (u UserStore) Verify(username, password string) bool {
if u == nil || len(u) == 0 {
return false
}
- hash, ok := u[username]
+ info, ok := u[username]
if !ok {
return false
}
- return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+ return bcrypt.CompareHashAndPassword([]byte(info.Password), []byte(password)) == nil
}
func (u UserStore) Save(path string) error {
@@ -67,7 +83,7 @@ func (u UserStore) Add(username, password string) error {
if err != nil {
return err
}
- u[username] = string(hash)
+ u[username] = UserInfo{Password: string(hash)}
return nil
}
@@ -84,14 +100,56 @@ func (u UserStore) ChangePassword(username, newPassword string) error {
if newPassword == "" {
return fmt.Errorf("password is required")
}
+ info, ok := u[username]
+ if !ok {
+ return fmt.Errorf("user not found")
+ }
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
- u[username] = string(hash)
+ info.Password = string(hash)
+ u[username] = info
return nil
}
+// SetSSHKeys replaces the SSH keys for a user.
+func (u UserStore) SetSSHKeys(username string, keys []string) error {
+ if u == nil {
+ return fmt.Errorf("user store not configured")
+ }
+ info, ok := u[username]
+ if !ok {
+ return fmt.Errorf("user not found")
+ }
+ info.SSHKeys = keys
+ u[username] = info
+ return nil
+}
+
+// HasAnyKeys returns true if any user has SSH keys configured.
+func (u UserStore) HasAnyKeys() bool {
+ for _, info := range u {
+ if len(info.SSHKeys) > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+// FindUserByKey returns the username whose SSH keys contain the given key.
+func (u UserStore) FindUserByKey(pubkeyFields []string) string {
+ for username, info := range u {
+ for _, k := range info.SSHKeys {
+ kf := strings.Fields(strings.TrimSpace(k))
+ if len(kf) >= 2 && pubkeyFields[0] == kf[0] && pubkeyFields[1] == kf[1] {
+ return username
+ }
+ }
+ }
+ return ""
+}
+
// ----- Cookie signing -----
func signCookie(username string, secret []byte) string {
diff --git a/auth_handlers.go b/auth_handlers.go
index 98c2add..af19a8d 100644
--- a/auth_handlers.go
+++ b/auth_handlers.go
@@ -1,6 +1,7 @@
package main
import (
+ "fmt"
"html/template"
"net/http"
"strings"
@@ -61,6 +62,10 @@ func SetupPost(users UserStore, usersPath string, secret []byte, tmpl *template.
tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": err.Error(), "User": User(r)})
return
}
+ // Save SSH key if provided.
+ if key := strings.TrimSpace(r.FormValue("ssh_key")); key != "" {
+ users.SetSSHKeys(username, []string{key})
+ }
if err := users.Save(usersPath); err != nil {
tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": "Failed to save: " + err.Error(), "User": User(r)})
return
@@ -82,9 +87,11 @@ func UsersGet(users UserStore, usersPath string, tmpl *template.Template) http.H
for u := range users {
userList = append(userList, u)
}
+ sshKeys := users[User(r)].SSHKeys
tmpl.ExecuteTemplate(w, "auth_users.html", map[string]any{
- "Users": userList,
- "User": User(r),
+ "Users": userList,
+ "User": User(r),
+ "SSHKeys": sshKeys,
})
}
}
@@ -121,6 +128,26 @@ func UsersPost(users UserStore, usersPath string, tmpl *template.Template) http.
return
}
users.Save(usersPath)
+ case "add-ssh-key":
+ currentUser := User(r)
+ if key := strings.TrimSpace(r.FormValue("ssh_key")); key != "" {
+ info := users[currentUser]
+ info.SSHKeys = append(info.SSHKeys, key)
+ users[currentUser] = info
+ users.Save(usersPath)
+ }
+ case "remove-ssh-key":
+ currentUser := User(r)
+ idx := r.FormValue("index")
+ if info, ok := users[currentUser]; ok {
+ var i int
+ fmt.Sscanf(idx, "%d", &i)
+ if i >= 0 && i < len(info.SSHKeys) {
+ info.SSHKeys = append(info.SSHKeys[:i], info.SSHKeys[i+1:]...)
+ users[currentUser] = info
+ users.Save(usersPath)
+ }
+ }
}
http.Redirect(w, r, "/users", http.StatusSeeOther)
}
diff --git a/main.go b/main.go
index f297d2b..87deb84 100644
--- a/main.go
+++ b/main.go
@@ -100,7 +100,7 @@ func main() {
// Start SSH server if configured
if cfg.SSHAddr != "" {
- sshSrv, err := NewSSHServer(cfg.Dir, cfg.SSHHostKey)
+ sshSrv, err := NewSSHServer(cfg.Dir, cfg.SSHHostKey, users)
if err != nil {
slog.Error("failed to create SSH server", "error", err)
os.Exit(1)
diff --git a/ssh.go b/ssh.go
index bbf6ceb..1f91587 100644
--- a/ssh.go
+++ b/ssh.go
@@ -3,6 +3,7 @@ package main
import (
"crypto/ed25519"
"crypto/rand"
+ "encoding/json"
"encoding/pem"
"fmt"
"io"
@@ -20,10 +21,11 @@ import (
type SSHServer struct {
reposDir string
+ users UserStore
config *ssh.ServerConfig
}
-func NewSSHServer(reposDir, hostKeyPath string) (*SSHServer, error) {
+func NewSSHServer(reposDir, hostKeyPath string, users UserStore) (*SSHServer, error) {
signer, err := loadOrGenerateHostKey(hostKeyPath)
if err != nil {
return nil, fmt.Errorf("host key: %w", err)
@@ -39,7 +41,7 @@ func NewSSHServer(reposDir, hostKeyPath string) (*SSHServer, error) {
}
config.AddHostKey(signer)
- return &SSHServer{reposDir: reposDir, config: config}, nil
+ return &SSHServer{reposDir: reposDir, users: users, config: config}, nil
}
func (s *SSHServer) ListenAndServe(addr string) error {
@@ -80,11 +82,11 @@ func (s *SSHServer) handleConn(conn net.Conn) {
if err != nil {
continue
}
- go s.handleSession(channel, requests)
+ go s.handleSession(channel, requests, sshConn.Permissions)
}
}
-func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Request) {
+func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Request, perms *ssh.Permissions) {
defer channel.Close()
for req := range requests {
@@ -93,7 +95,7 @@ func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Requ
case "exec":
ssh.Unmarshal(req.Payload, &execMsg)
req.Reply(true, nil)
- s.runGitCommand(channel, execMsg.Command)
+ s.runGitCommand(channel, execMsg.Command, perms)
return
case "env":
req.Reply(false, nil)
@@ -103,7 +105,7 @@ func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Requ
}
}
-func (s *SSHServer) runGitCommand(channel ssh.Channel, gitCmd string) {
+func (s *SSHServer) runGitCommand(channel ssh.Channel, gitCmd string, perms *ssh.Permissions) {
parts := strings.SplitN(strings.TrimSpace(gitCmd), " ", 2)
if len(parts) != 2 {
io.WriteString(channel.Stderr(), "fatal: invalid git command\n")
@@ -133,7 +135,13 @@ func (s *SSHServer) runGitCommand(channel ssh.Channel, gitCmd string) {
}
switch subCmd {
- case "git-upload-pack", "git-upload-archive", "git-receive-pack":
+ case "git-upload-pack", "git-upload-archive":
+ case "git-receive-pack":
+ if !s.checkPushAuth(repoPath, perms) {
+ io.WriteString(channel.Stderr(), "fatal: SSH key not authorized to push to this repository\n")
+ channel.SendRequest("exit-status", false, ssh.Marshal(&struct{ ExitStatus uint32 }{1}))
+ return
+ }
default:
io.WriteString(channel.Stderr(), "fatal: unknown git command: "+subCmd+"\n")
return
@@ -177,6 +185,51 @@ func (s *SSHServer) runGitCommand(channel ssh.Channel, gitCmd string) {
time.Sleep(50 * time.Millisecond)
}
+// checkPushAuth returns true if the push should be allowed. The key must match
+// either per-repo authorized_keys or a user's registered SSH keys.
+func (s *SSHServer) checkPushAuth(repoPath string, perms *ssh.Permissions) bool {
+ if perms == nil {
+ return false
+ }
+ pubkey := strings.TrimSpace(perms.Extensions["pubkey"])
+ if pubkey == "" {
+ return false
+ }
+ pubFields := strings.Fields(pubkey)
+ if len(pubFields) < 2 {
+ return false
+ }
+
+ // Check per-repo authorized_keys.
+ data, err := os.ReadFile(filepath.Join(repoPath, ".fierj.json"))
+ var repoKeys int
+ if err == nil {
+ var meta RepoMeta
+ if err := json.Unmarshal(data, &meta); err == nil && len(meta.AuthorizedKeys) > 0 {
+ repoKeys = len(meta.AuthorizedKeys)
+ for _, k := range meta.AuthorizedKeys {
+ kFields := strings.Fields(strings.TrimSpace(k))
+ if len(kFields) >= 2 && pubFields[0] == kFields[0] && pubFields[1] == kFields[1] {
+ return true
+ }
+ }
+ }
+ }
+
+ // Check user-level SSH keys.
+ if s.users.FindUserByKey(pubFields) != "" {
+ return true
+ }
+
+ // If no authorization is configured anywhere, allow (backward compat).
+ if repoKeys == 0 && !s.users.HasAnyKeys() {
+ return true
+ }
+
+ slog.Warn("ssh push rejected", "repo", filepath.Base(repoPath))
+ return false
+}
+
func loadOrGenerateHostKey(path string) (ssh.Signer, error) {
if path == "" {
path = "fierj_host_key"
diff --git a/templates/auth_setup.html b/templates/auth_setup.html
index 43e8b00..84e9e37 100644
--- a/templates/auth_setup.html
+++ b/templates/auth_setup.html
@@ -26,6 +26,11 @@
<label for="confirm">Confirm password</label>
<input type="password" id="confirm" name="confirm" required minlength="6">
</div>
+ <div class="form-group">
+ <label for="ssh_key">SSH public key (optional — enables git push)</label>
+ <textarea id="ssh_key" name="ssh_key" rows="3" placeholder="ssh-ed25519 AAAAC3..."></textarea>
+ <span class="field-hint">Paste your public key to push via SSH without per-repo setup.</span>
+ </div>
<button type="submit" class="btn">Create account</button>
</form>
</div>
diff --git a/templates/auth_users.html b/templates/auth_users.html
index c3392fd..8fa0196 100644
--- a/templates/auth_users.html
+++ b/templates/auth_users.html
@@ -35,6 +35,34 @@
</div>
</div>
+<!-- SSH Keys -->
+<div class="card mt-lg">
+ <h2>Your SSH keys</h2>
+ <p class="subtitle">SSH keys allow you to push to any repository without per-repo configuration.</p>
+ {{if .SSHKeys}}
+ <div class="list-box mb-md">
+ {{range $i, $k := .SSHKeys}}
+ <div class="list-row">
+ <code style="font-size:0.8rem;word-break:break-all;">{{$k}}</code>
+ <form method="post" onsubmit="return confirm('Remove this key?')">
+ <input type="hidden" name="action" value="remove-ssh-key">
+ <input type="hidden" name="index" value="{{$i}}">
+ <button type="submit" class="btn-subtle btn-sm">Remove</button>
+ </form>
+ </div>
+ {{end}}
+ </div>
+ {{end}}
+ <form method="post">
+ <input type="hidden" name="action" value="add-ssh-key">
+ <div class="form-group">
+ <label for="ssh_key">Add SSH public key</label>
+ <textarea id="ssh_key" name="ssh_key" rows="2" placeholder="ssh-ed25519 AAAAC3..."></textarea>
+ </div>
+ <button type="submit" class="btn">Add key</button>
+ </form>
+</div>
+
<!-- User list -->
<div class="mt-xl">
<h2 class="section-title">All users ({{len .Users}})</h2>