~edwargix/git.sr.ht

6696d28721d0ebf06974d876d228524d945d669f — Drew DeVault 6 years ago da60312
Rewrite gitsrht-shell in golang
5 files changed, 248 insertions(+), 81 deletions(-)

D gitsrht-shell
A gitsrht-shell/.gitignore
A gitsrht-shell/go.mod
A gitsrht-shell/go.sum
A gitsrht-shell/main.go
D gitsrht-shell => gitsrht-shell +0 -81
@@ 1,81 0,0 @@
#!/usr/bin/env python3
import sys
import os
try:
    f = open("/var/log/git-srht-shell", "a")
    os.close(sys.stderr.fileno())
    os.dup2(f.fileno(), sys.stderr.fileno())
except Exception as ex:
    sys.stderr.write("Unable to open log for writing\n")
    sys.stderr.write(str(ex) + "\n")
import json
import requests
import shlex
from datetime import datetime
from srht.config import cfg, get_origin
from srht.crypto import sign_payload
from srht.validation import Validation

def log(s, *args):
    sys.stderr.write("{} {}\n".format(datetime.now().isoformat(),
        s.format(*args) if isinstance(s, str) else str(s)))

origin = get_origin("git.sr.ht")
repos = cfg("git.sr.ht", "repos")

_cmd = os.environ.get("SSH_ORIGINAL_COMMAND")
if not _cmd:
    _cmd = ""
if len(sys.argv) < 2:
    log("Error: expected 2 arguments from SSH")
    sys.exit(1)
user_id = int(sys.argv[1])
username = sys.argv[2]
ssh_key = sys.argv[3]

log("User: {}", username)

cmd = shlex.split(_cmd)
valid_commands = ["git-receive-pack", "git-upload-pack", "git-upload-archive"]
if len(cmd) < 1 or not cmd[0] in valid_commands:
    log("Not permitting unacceptable command")
    print("Hi {}! You've successfully authenticated, ".format(username) +
        "but I do not provide an interactive shell. Bye!")
    sys.exit(128)
os.chdir(repos)
path = os.path.abspath(cmd[-1])
if not path.startswith(repos):
    path = os.path.join(repos, path)
cmd[-1] = path

# Delegate to web application for validation
payload = {
    "path": path,
    "user_id": user_id,
    # 2 is write, 1 is read
    "access": 2 if cmd[0] == "git-receive-pack" else 1,
}
payload = json.dumps(payload)
headers = {
    "Content-Type": "application/json",
}
headers.update(sign_payload(payload))
r = requests.post(f"{origin}/internal/push-check",
        data=payload, headers=headers)
sys.stderr.write(r.text + "\n")
response = r.json()
if r.status_code == 302:
    print("\n\t\033[93mNOTICE\033[0m\n")
    print("\tThis repository has moved:\n")
    print(f"\t{response['redirect']}\n")
    print("\tPlease update your remote.\n\n")
    sys.exit(128)
elif r.status_code == 200:
    os.environ["SRHT_PUSH_CTX"] = r.text
    log("Executing {}", " ".join(cmd))
    sys.stderr.close()
    os.execvp(cmd[0], cmd)
else:
    if "why" in response:
        print(response["why"])
    sys.exit(128)

A gitsrht-shell/.gitignore => gitsrht-shell/.gitignore +1 -0
@@ 0,0 1,1 @@
gitsrht-shell

A gitsrht-shell/go.mod => gitsrht-shell/go.mod +9 -0
@@ 0,0 1,9 @@
module git.sr.ht/~sircmpwn/git.sr.ht/gitsrht-shell

require (
	github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf
	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec
	golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550
)

go 1.13

A gitsrht-shell/go.sum => gitsrht-shell/go.sum +11 -0
@@ 0,0 1,11 @@
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg=
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7tbLt/I/gQuP559o/0FrAkHKlQM/Ks=
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

A gitsrht-shell/main.go => gitsrht-shell/main.go +227 -0
@@ 0,0 1,227 @@
package main

import (
	"bytes"
	"crypto/ed25519"
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	gopath "path"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"

	"github.com/google/shlex"
	"github.com/vaughan0/go-ini"
)

func main() {
	var (
		config ini.File
		err    error
		logger *log.Logger

		userId   int
		username string

		origin   string
		repos    string
		privkey  ed25519.PrivateKey

		cmdstr   string
		cmd      []string
	)

	logf, err := os.OpenFile("/var/log/gitsrht-shell",
		os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
	if err != nil {
		log.Printf("Warning: unable to open log file: %v " +
			"(using stderr instead)", err)
		logger = log.New(os.Stderr, "", log.LstdFlags)
	} else {
		logger = log.New(logf, "", log.LstdFlags)
	}

	if len(os.Args) < 2 {
		logger.Fatalf("Expected two arguments from SSH")
	}
	logger.Printf("os.Args: %v", os.Args)

	if userId, err = strconv.Atoi(os.Args[1]); err != nil {
		logger.Fatalf("Couldn't interpret user ID: %v", err)
	}
	username = os.Args[2]

	for _, path := range []string{"../config.ini", "/etc/sr.ht/config.ini"} {
		config, err = ini.LoadFile(path)
		if err == nil {
			break
		}
	}
	if err != nil {
		logger.Fatalf("Failed to load config file: %v", err)
	}

	origin, ok := config.Get("git.sr.ht", "internal-origin")
	if !ok {
		origin, ok = config.Get("git.sr.ht", "origin")
	}
	if !ok || origin == "" {
		logger.Fatalf("No origin configured for git.sr.ht")
	}

	repos, ok = config.Get("git.sr.ht", "repos")
	if !ok {
		logger.Fatalf("No repo path configured for git.sr.ht")
	}

	b64key, ok := config.Get("webhooks", "private-key")
	if !ok {
		logger.Fatalf("No webhook key configured")
	}
	seed, err := base64.StdEncoding.DecodeString(b64key)
	if err != nil {
		logger.Fatalf("base64 decode webhooks private key: %v", err)
	}
	privkey = ed25519.NewKeyFromSeed(seed)

	cmdstr, ok = os.LookupEnv("SSH_ORIGINAL_COMMAND")
	if !ok {
		cmdstr = ""
	}

	cmd, err = shlex.Split(cmdstr)
	if err != nil {
		logger.Fatalf("Unable to parse command: %v", err)
	}

	logger.Println("Running git.sr.ht shell")

	validCommands := []string{
		"git-receive-pack", "git-upload-pack", "git-upload-archive",
	}
	var valid bool
	for _, c := range validCommands {
		if len(cmd) > 0 && c == cmd[0] {
			valid = true
		}
	}

	if !valid {
		logger.Printf("Not permitting unacceptable command: %v", cmd)
		fmt.Printf("Hi %s! You've successfully authenticated, " +
			"but I do not provide an interactive shell. Bye!\n", username)
		os.Exit(128)
	}

	os.Chdir(repos)

	path := cmd[len(cmd)-1]
	path, err = filepath.Abs(path)
	if err != nil {
		logger.Fatalf("filepath.Abs(%s): %v", path, err)
	}
	if !strings.HasPrefix(path, repos) {
		path = gopath.Join(repos, path)
	}
	cmd[len(cmd)-1] = path

	access := 1
	if cmd[0] == "git-receive-pack" {
		access = 2
	}

	payload, err := json.Marshal(struct {
		Access int    `json:"access"`
		Path   string `json:"path"`
		UserId int    `json:"user_id"`
	}{
		Access: access,
		Path:   path,
		UserId: userId,
	})
	if err != nil {
		logger.Fatalf("json.Marshal: %v", err)
	}
	logger.Println(string(payload))

	var (
		nonceSeed []byte
		nonceHex  []byte
	)
	_, err = rand.Read(nonceSeed)
	if err != nil {
		logger.Fatalf("generate nonce: %v", err)
	}
	hex.Encode(nonceHex, nonceSeed)
	signature := ed25519.Sign(privkey, append(payload, nonceHex...))

	headers := make(http.Header)
	headers.Add("Content-Type", "application/json")
	headers.Add("X-Payload-Nonce", string(nonceHex))
	headers.Add("X-Payload-Signature",
		base64.StdEncoding.EncodeToString(signature))

	check, err := url.Parse(fmt.Sprintf("%s/internal/push-check", origin))
	if err != nil {
		logger.Fatalf("url.Parse: %v", err)
	}
	req := http.Request{
		Body:          ioutil.NopCloser(bytes.NewBuffer(payload)),
		ContentLength: int64(len(payload)),
		Header:        headers,
		Method:        "POST",
		URL:           check,
	}
	resp, err := http.DefaultClient.Do(&req)
	if err != nil {
		logger.Fatalf("http.Client.Do: %v", err)
	}
	defer resp.Body.Close()
	results, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		logger.Fatal("ReadAll(resp.Body): %v", err)
	}
	logger.Println(string(results))

	switch resp.StatusCode {
	case 302:
		var redirect struct {
			Redirect string `json:"redirect"`
		}
		json.Unmarshal(results, &redirect)

		fmt.Printf("\n\t\033[93mNOTICE\033[0m\n\n")
		fmt.Printf("\tThis repository has moved:\n\n")
		fmt.Printf("\t%s\n\n", redirect.Redirect)
		fmt.Printf("\tPlease update your remote.\n\n\n")
		os.Exit(128)
	case 200:
		logger.Printf("Executing command: %v", cmd)
		bin, err := exec.LookPath(cmd[0])
		if err != nil {
			logger.Fatalf("exec.LookPath: %v", err)
		}
		if err := syscall.Exec(bin, cmd,
			append(os.Environ(), fmt.Sprintf(
				"SRHT_PUSH_CTX=%s", string(results)))); err != nil {
					logger.Fatalf("syscall.Exec: %v", err)
		}
	default:
		var why struct {
			Why string `json:"why"`
		}
		json.Unmarshal(results, &why)
		fmt.Println(why.Why)
		os.Exit(128)
	}
}