Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
hooks, fedauth: Add basic federated authentication for git push
package main
import (
"bufio"
"context"
"errors"
"io"
"net/http"
"net/url"
"strings"
"github.com/jackc/pgx/v5"
)
func check_and_update_federated_user_status(ctx context.Context, user_id int, service, remote_username, pubkey string) (bool, error) {
switch service {
case "sr.ht":
username_escaped := url.PathEscape(remote_username)
resp, err := http.Get("https://meta.sr.ht/~" + username_escaped + ".keys")
if err != nil {
return false, err
}
defer func() {
_ = resp.Body.Close()
}()
buf := bufio.NewReader(resp.Body)
matched := false
for {
line, err := buf.ReadString('\n')
if errors.Is(err, io.EOF) {
break
} else if err != nil {
return false, err
}
line_split := strings.Split(line, " ")
if len(line_split) < 2 {
continue
}
line = strings.Join(line_split[:2], " ")
if line == pubkey {
matched = true
break
}
}
if !matched {
return false, nil
}
var tx pgx.Tx
if tx, err = database.Begin(ctx); err != nil {
return false, err
}
defer func() {
_ = tx.Rollback(ctx)
}()
if _, err = tx.Exec(ctx, `UPDATE users SET type = 'federated' WHERE id = $1 AND type = 'pubkey_only'`, user_id); err != nil {
return false, err
}
if _, err = tx.Exec(ctx, `INSERT INTO federated_identities (user_id, service, remote_username) VALUES ($1, $2, $3)`, user_id, service, remote_username); err != nil {
return false, err
}
if err = tx.Commit(ctx); err != nil {
return false, err
}
return true, nil
default:
return false, errors.New("unknown federated service")
}
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileContributor: Runxi Yu <https://runxiyu.org>
package main
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/jackc/pgx/v5"
"go.lindenii.runxiyu.org/lindenii-common/ansiec"
)
var (
err_get_fd = errors.New("unable to get file descriptor")
err_get_ucred = errors.New("failed getsockopt")
)
// hooks_handle_connection handles a connection from git_hooks_client via the
// unix socket.
func hooks_handle_connection(conn net.Conn) {
var ctx context.Context
var cancel context.CancelFunc
var ucred *syscall.Ucred
var err error
var cookie []byte
var pack_to_hook pack_to_hook_t
var ssh_stderr io.Writer
var ok bool
var hook_return_value byte
defer conn.Close()
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
// There aren't reasonable cases where someone would run this as
// another user.
if ucred, err = get_ucred(conn); err != nil {
if _, err = conn.Write([]byte{1}); err != nil {
return
}
wf_error(conn, "\nUnable to get peer credentials: %v", err)
return
}
if ucred.Uid != uint32(os.Getuid()) {
if _, err = conn.Write([]byte{1}); err != nil {
return
}
wf_error(conn, "\nUID mismatch")
return
}
cookie = make([]byte, 64)
if _, err = conn.Read(cookie); err != nil {
if _, err = conn.Write([]byte{1}); err != nil {
return
}
wf_error(conn, "\nFailed to read cookie: %v", err)
return
}
pack_to_hook, ok = pack_to_hook_by_cookie.Load(string(cookie))
if !ok {
if _, err = conn.Write([]byte{1}); err != nil {
return
}
wf_error(conn, "\nInvalid handler cookie")
return
}
ssh_stderr = pack_to_hook.session.Stderr()
_, _ = ssh_stderr.Write([]byte{'\n'})
hook_return_value = func() byte {
var argc64 uint64
if err = binary.Read(conn, binary.NativeEndian, &argc64); err != nil {
wf_error(ssh_stderr, "Failed to read argc: %v", err)
return 1
}
var args []string
for i := uint64(0); i < argc64; i++ {
var arg bytes.Buffer
for {
b := make([]byte, 1)
n, err := conn.Read(b)
if err != nil || n != 1 {
wf_error(ssh_stderr, "Failed to read arg: %v", err)
return 1
}
if b[0] == 0 {
break
}
arg.WriteByte(b[0])
}
args = append(args, arg.String())
}
git_env := make(map[string]string)
for {
var env_line bytes.Buffer
for {
b := make([]byte, 1)
n, err := conn.Read(b)
if err != nil || n != 1 {
wf_error(ssh_stderr, "Failed to read environment variable: %v", err)
return 1
}
if b[0] == 0 {
break
}
env_line.WriteByte(b[0])
}
if env_line.Len() == 0 {
break
}
kv := env_line.String()
parts := strings.SplitN(kv, "=", 2)
if len(parts) < 2 {
wf_error(ssh_stderr, "Invalid environment variable line: %v", kv)
return 1
}
git_env[parts[0]] = parts[1]
}
var stdin bytes.Buffer
if _, err = io.Copy(&stdin, conn); err != nil {
wf_error(conn, "Failed to read to the stdin buffer: %v", err)
}
switch filepath.Base(args[0]) {
case "pre-receive":
if pack_to_hook.direct_access {
return 0
} else {
all_ok := true
for {
var line, old_oid, rest, new_oid, ref_name string
var found bool
var old_hash, new_hash plumbing.Hash
var old_commit, new_commit *object.Commit
var git_push_option_count int
git_push_option_count, err = strconv.Atoi(git_env["GIT_PUSH_OPTION_COUNT"])
if err != nil {
wf_error(ssh_stderr, "Failed to parse GIT_PUSH_OPTION_COUNT: %v", err)
return 1
}
// TODO: Allow existing users (even if they are already federated or registered) to add a federated user ID... though perhaps this should be in the normal SSH interface instead of the git push interface?
// Also it'd be nice to be able to combine users or whatever
if pack_to_hook.contrib_requirements == "federated" && pack_to_hook.user_type != "federated" && pack_to_hook.user_type != "registered" {
if git_push_option_count == 0 {
wf_error(ssh_stderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
return 1
}
for i := 0; i < git_push_option_count; i++ {
push_option, ok := git_env[fmt.Sprintf("GIT_PUSH_OPTION_%d", i)]
if !ok {
wf_error(ssh_stderr, "Failed to get push option %d", i)
return 1
}
if strings.HasPrefix(push_option, "fedid=") {
federated_user_identifier := strings.TrimPrefix(push_option, "fedid=")
service, username, found := strings.Cut(federated_user_identifier, ":")
if !found {
wf_error(ssh_stderr, "Invalid federated user identifier %#v does not contain a colon", federated_user_identifier)
return 1
}
ok, err := check_and_update_federated_user_status(ctx, pack_to_hook.user_id, service, username, pack_to_hook.pubkey)
if err != nil {
wf_error(ssh_stderr, "Failed to verify federated user identifier %#v: %v", federated_user_identifier, err)
return 1
}
if !ok {
wf_error(ssh_stderr, "Failed to verify federated user identifier %#v: you don't seem to be on the list", federated_user_identifier)
return 1
}
break
}
if i == git_push_option_count-1 {
wf_error(ssh_stderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
return 1
}
}
}
line, err = stdin.ReadString('\n')
if errors.Is(err, io.EOF) {
break
} else if err != nil {
wf_error(ssh_stderr, "Failed to read pre-receive line: %v", err)
return 1
}
line = line[:len(line)-1]
old_oid, rest, found = strings.Cut(line, " ")
if !found {
wf_error(ssh_stderr, "Invalid pre-receive line: %v", line)
return 1
}
new_oid, ref_name, found = strings.Cut(rest, " ")
if !found {
wf_error(ssh_stderr, "Invalid pre-receive line: %v", line)
return 1
}
if strings.HasPrefix(ref_name, "refs/heads/contrib/") {
if all_zero_num_string(old_oid) { // New branch
fmt.Fprintln(ssh_stderr, ansiec.Blue+"POK"+ansiec.Reset, ref_name)
var new_mr_id int
err = database.QueryRow(ctx,
"INSERT INTO merge_requests (repo_id, creator, source_ref, status) VALUES ($1, $2, $3, 'open') RETURNING id",
pack_to_hook.repo_id, pack_to_hook.user_id, strings.TrimPrefix(ref_name, "refs/heads/"),
).Scan(&new_mr_id)
if err != nil {
wf_error(ssh_stderr, "Error creating merge request: %v", err)
return 1
}
fmt.Fprintln(ssh_stderr, ansiec.Blue+"Created merge request at", generate_http_remote_url(pack_to_hook.group_path, pack_to_hook.repo_name)+"/contrib/"+strconv.FormatUint(uint64(new_mr_id), 10)+"/"+ansiec.Reset)
} else { // Existing contrib branch
var existing_merge_request_user_id int
var is_ancestor bool
err = database.QueryRow(ctx,
"SELECT COALESCE(creator, 0) FROM merge_requests WHERE source_ref = $1 AND repo_id = $2",
strings.TrimPrefix(ref_name, "refs/heads/"), pack_to_hook.repo_id,
).Scan(&existing_merge_request_user_id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
wf_error(ssh_stderr, "No existing merge request for existing contrib branch: %v", err)
} else {
wf_error(ssh_stderr, "Error querying for existing merge request: %v", err)
}
return 1
}
if existing_merge_request_user_id == 0 {
all_ok = false
fmt.Fprintln(ssh_stderr, ansiec.Red+"NAK"+ansiec.Reset, ref_name, "(branch belongs to unowned MR)")
continue
}
if existing_merge_request_user_id != pack_to_hook.user_id {
all_ok = false
fmt.Fprintln(ssh_stderr, ansiec.Red+"NAK"+ansiec.Reset, ref_name, "(branch belongs another user's MR)")
continue
}
old_hash = plumbing.NewHash(old_oid)
if old_commit, err = pack_to_hook.repo.CommitObject(old_hash); err != nil {
wf_error(ssh_stderr, "Daemon failed to get old commit: %v", err)
return 1
}
// Potential BUG: I'm not sure if new_commit is guaranteed to be
// detectable as they haven't been merged into the main repo's
// objects yet. But it seems to work, and I don't think there's
// any reason for this to only work intermitently.
new_hash = plumbing.NewHash(new_oid)
if new_commit, err = pack_to_hook.repo.CommitObject(new_hash); err != nil {
wf_error(ssh_stderr, "Daemon failed to get new commit: %v", err)
return 1
}
if is_ancestor, err = old_commit.IsAncestor(new_commit); err != nil {
wf_error(ssh_stderr, "Daemon failed to check if old commit is ancestor: %v", err)
return 1
}
if !is_ancestor {
// TODO: Create MR snapshot ref instead
all_ok = false
fmt.Fprintln(ssh_stderr, ansiec.Red+"NAK"+ansiec.Reset, ref_name, "(force pushes are not supported yet)")
continue
}
fmt.Fprintln(ssh_stderr, ansiec.Blue+"POK"+ansiec.Reset, ref_name)
}
} else { // Non-contrib branch
all_ok = false
fmt.Fprintln(ssh_stderr, ansiec.Red+"NAK"+ansiec.Reset, ref_name, "(you cannot push to branches outside of contrib/*)")
}
}
fmt.Fprintln(ssh_stderr)
if all_ok {
fmt.Fprintln(ssh_stderr, "Overall "+ansiec.Green+"ACK"+ansiec.Reset+" (all checks passed)")
return 0
} else {
fmt.Fprintln(ssh_stderr, "Overall "+ansiec.Red+"NAK"+ansiec.Reset+" (one or more branches failed checks)")
return 1
}
}
default:
fmt.Fprintln(ssh_stderr, ansiec.Red+"Invalid hook:", args[0]+ansiec.Reset)
return 1
}
}()
fmt.Fprintln(ssh_stderr)
_, _ = conn.Write([]byte{hook_return_value})
}
func serve_git_hooks(listener net.Listener) error {
for {
conn, err := listener.Accept()
if err != nil {
return err
}
go hooks_handle_connection(conn)
}
}
func get_ucred(conn net.Conn) (ucred *syscall.Ucred, err error) {
var unix_conn *net.UnixConn = conn.(*net.UnixConn)
var fd *os.File
if fd, err = unix_conn.File(); err != nil {
return nil, err_get_fd
}
defer fd.Close()
if ucred, err = syscall.GetsockoptUcred(int(fd.Fd()), syscall.SOL_SOCKET, syscall.SO_PEERCRED); err != nil {
return nil, err_get_ucred
}
return ucred, nil
}
func all_zero_num_string(s string) bool {
for _, r := range s {
if r != '0' {
return false
}
}
return true
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileContributor: Runxi Yu <https://runxiyu.org>
package main
import (
"github.com/go-git/go-git/v5"
git_config "github.com/go-git/go-git/v5/config"
git_format_config "github.com/go-git/go-git/v5/plumbing/format/config"
)
// git_bare_init_with_default_hooks initializes a bare git repository with the
// forge-deployed hooks directory as the hooksPath.
func git_bare_init_with_default_hooks(repo_path string) (err error) {
var repo *git.Repository
var git_config *git_config.Config
if repo, err = git.PlainInit(repo_path, true); err != nil {
return err
}
if git_config, err = repo.Config(); err != nil {
return err
}
git_config.Raw.SetOption("core", git_format_config.NoSubsection, "hooksPath", config.Hooks.Execs)
git_config.Raw.SetOption("receive", git_format_config.NoSubsection, "advertisePushOptions", "true")
if err = repo.SetConfig(git_config); err != nil {
return err
}
return nil
}
-- SPDX-License-Identifier: AGPL-3.0-only
-- SPDX-FileContributor: Runxi Yu <https://runxiyu.org>
CREATE TABLE groups (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
parent_group INTEGER REFERENCES groups(id) ON DELETE CASCADE,
description TEXT,
UNIQUE NULLS NOT DISTINCT (parent_group, name)
);
CREATE TABLE repos (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE RESTRICT, -- I mean, should be CASCADE but deleting Git repos on disk also needs to be considered
contrib_requirements TEXT NOT NULL CHECK (contrib_requirements IN ('closed', 'registered_user', 'federated', 'ssh_pubkey', 'public')),
name TEXT NOT NULL,
UNIQUE(group_id, name),
description TEXT,
filesystem_path TEXT
);
CREATE TABLE ticket_trackers (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
UNIQUE(group_id, name),
description TEXT
);
CREATE TABLE tickets (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tracker_id INTEGER NOT NULL REFERENCES ticket_trackers(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT
);
CREATE TABLE mailing_lists (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
UNIQUE(group_id, name),
description TEXT
);
CREATE TABLE mailing_list_emails (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
list_id INTEGER NOT NULL REFERENCES mailing_lists(id) ON DELETE CASCADE,
title TEXT NOT NULL,
sender TEXT NOT NULL,
date TIMESTAMP NOT NULL,
content BYTEA NOT NULL
);
CREATE TABLE users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username TEXT UNIQUE,
type TEXT NOT NULL CHECK (type IN ('pubkey_only', 'federated', 'registered')),
password TEXT
);
CREATE TABLE ssh_public_keys (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
key_string TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT unique_key_string EXCLUDE USING HASH (key_string WITH =)
);
CREATE TABLE sessions (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id TEXT PRIMARY KEY NOT NULL,
UNIQUE(user_id, session_id)
);
-- TODO:
CREATE TABLE merge_requests (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
creator INTEGER REFERENCES users(id) ON DELETE SET NULL,
source_ref TEXT NOT NULL,
destination_branch TEXT,
status TEXT NOT NULL CHECK (status IN ('open', 'merged', 'closed')),
UNIQUE (repo_id, source_ref, destination_branch),
UNIQUE (repo_id, id)
);
CREATE TABLE user_group_roles (
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY(user_id, group_id)
);
CREATE TABLE federated_identities ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, service TEXT NOT NULL, remote_username TEXT NOT NULL, PRIMARY KEY(user_id, service) );
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileContributor: Runxi Yu <https://runxiyu.org>
package main
import (
"errors"
"fmt"
"os"
"os/exec"
glider_ssh "github.com/gliderlabs/ssh"
"github.com/go-git/go-git/v5"
"go.lindenii.runxiyu.org/lindenii-common/cmap"
)
type pack_to_hook_t struct {
session glider_ssh.Session repo *git.Repository pubkey string direct_access bool repo_path string user_id int repo_id int group_path []string repo_name string
session glider_ssh.Session repo *git.Repository pubkey string direct_access bool repo_path string user_id int user_type string repo_id int group_path []string repo_name string contrib_requirements string
}
var pack_to_hook_by_cookie = cmap.Map[string, pack_to_hook_t]{}
// ssh_handle_receive_pack handles attempts to push to repos.
func ssh_handle_receive_pack(session glider_ssh.Session, pubkey string, repo_identifier string) (err error) {
group_path, repo_name, repo_id, repo_path, direct_access, contrib_requirements, user_type, user_id, err := get_repo_path_perms_from_ssh_path_pubkey(session.Context(), repo_identifier, pubkey)
if err != nil {
return err
}
repo, err := git.PlainOpen(repo_path)
if err != nil {
return err
}
repo_config, err := repo.Config()
if err != nil {
return err
}
repo_config_core := repo_config.Raw.Section("core")
if repo_config_core == nil {
return errors.New("Repository has no core section in config")
}
hooksPath := repo_config_core.OptionAll("hooksPath")
if len(hooksPath) != 1 || hooksPath[0] != config.Hooks.Execs {
return errors.New("Repository has hooksPath set to an unexpected value")
}
if !direct_access {
switch contrib_requirements {
case "closed":
if !direct_access {
return errors.New("You need direct access to push to this repo.")
}
case "registered_user":
if user_type != "registered" {
return errors.New("You need to be a registered user to push to this repo.")
}
case "ssh_pubkey":
fallthrough case "federated":
if pubkey == "" {
return errors.New("You need to have an SSH public key to push to this repo.")
}
if user_type == "" {
user_id, err = add_user_ssh(session.Context(), pubkey)
if err != nil {
return err
}
fmt.Fprintln(session.Stderr(), "You are now registered as user ID", user_id)
user_type = "pubkey_only"
}
case "public":
default:
panic("unknown contrib_requirements value " + contrib_requirements)
}
}
cookie, err := random_urlsafe_string(16)
if err != nil {
fmt.Fprintln(session.Stderr(), "Error while generating cookie:", err)
}
pack_to_hook_by_cookie.Store(cookie, pack_to_hook_t{
session: session, pubkey: pubkey, direct_access: direct_access, repo_path: repo_path, user_id: user_id, repo_id: repo_id, group_path: group_path, repo_name: repo_name, repo: repo,
session: session, pubkey: pubkey, direct_access: direct_access, repo_path: repo_path, user_id: user_id, repo_id: repo_id, group_path: group_path, repo_name: repo_name, repo: repo, contrib_requirements: contrib_requirements, user_type: user_type,
})
defer pack_to_hook_by_cookie.Delete(cookie)
// The Delete won't execute until proc.Wait returns unless something
// horribly wrong such as a panic occurs.
proc := exec.CommandContext(session.Context(), "git-receive-pack", repo_path)
proc.Env = append(os.Environ(),
"LINDENII_FORGE_HOOKS_SOCKET_PATH="+config.Hooks.Socket,
"LINDENII_FORGE_HOOKS_COOKIE="+cookie,
)
proc.Stdin = session
proc.Stdout = session
proc.Stderr = session.Stderr()
if err = proc.Start(); err != nil {
fmt.Fprintln(session.Stderr(), "Error while starting process:", err)
return err
}
err = proc.Wait()
if exitError, ok := err.(*exec.ExitError); ok {
fmt.Fprintln(session.Stderr(), "Process exited with error", exitError.ExitCode())
} else if err != nil {
fmt.Fprintln(session.Stderr(), "Error while waiting for process:", err)
}
return err
}
{{/*
SPDX-License-Identifier: AGPL-3.0-only
SPDX-FileContributor: Runxi Yu <https://runxiyu.org>
*/}}
{{- define "group" -}}
{{ $group_path := .group_path }}
<!DOCTYPE html>
<html lang="en">
<head>
{{ template "head_common" . }}
<title>{{ range $i, $s := .group_path }}{{ $s }}{{ if ne $i (len $group_path) }} / {{ end }}{{ end }} – {{ .global.forge_title }}</title>
</head>
<body class="group">
{{ template "header" . }}
<div class="padding-wrapper">
<p>{{ range $i, $s := .group_path }}{{ $s }}{{ if ne $i (len $group_path) }} / {{ end }}{{ end }}
{{ if .description }}
<p>{{ .description }}</p>
{{ end }}
{{ template "group_view" . }}
</div>
{{ if .direct_access }}
<div class="padding-wrapper">
<form method="POST" enctype="application/x-www-form-urlencoded">
<table>
<thead>
<tr>
<th class="title-row" colspan="2">
Create repo
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Name</th>
<td class="tdinput">
<input id="repo-name-input" name="repo_name" type="text" />
</td>
</tr>
<tr>
<th scope="row">Description</th>
<td class="tdinput">
<input id="repo-desc-input" name="repo_desc" type="text" />
</td>
</tr>
<tr>
<th scope="row">Contrib</th>
<td class="tdinput">
<select id="repo-contrib-input" name="repo_contrib">
<option value="public">Public</option>
<option value="ssh_pubkey">SSH public key</option>
<option value="federated">Federated service</option>
<option value="registered_user">Registered user</option>
<option value="closed">Closed</option>
</select>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td class="th-like" colspan="2">
<div class="flex-justify">
<div class="left">
</div>
<div class="right">
<input class="btn-primary" type="submit" value="Create" />
</div>
</div>
</td>
</tr>
</tfoot>
</table>
</form>
</div>
{{ end }}
<footer>
{{ template "footer" . }}
</footer>
</body>
</html>
{{- end -}}