Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The biggest missing features that will be implemented soon:

- [x] Multiple users and multiple repos
- [x] `git push`
- [ ] Access via ssh
- [x] Access via ssh
- [ ] Pull requests

```sh
Expand Down
8 changes: 7 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,11 @@
"avatars_path": "./avatars",
"hostname": "gitly.org",
"ci_service_url": "http://localhost:8081",
"usdt_wallet": ""
"usdt_wallet": "",
"ssh": {
"enabled": false,
"git_user": "git",
"authorized_keys_path": "/home/git/.ssh/authorized_keys",
"binary_path": "/usr/local/bin/gitly"
}
}
9 changes: 9 additions & 0 deletions config/loader.v
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ pub:
port int
pg PgConfig
sqlite SqliteConfig
ssh SshConfig
}

pub struct SshConfig {
pub:
enabled bool
git_user string = 'git'
authorized_keys_path string = '/home/git/.ssh/authorized_keys'
binary_path string = '/usr/local/bin/gitly'
}

pub struct PgConfig {
Expand Down
14 changes: 14 additions & 0 deletions main.v
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,27 @@ fn main() {
if os.args.contains('ci_run') {
return
}

ssh_shell_idx := os.args.index('-ssh-shell')
if ssh_shell_idx >= 0 && os.args.len > ssh_shell_idx + 1 {
user_id := os.args[ssh_shell_idx + 1].int()
conf := config.read_config('./config.json') or { panic(err) }
run_ssh_shell(user_id, conf) or {
eprintln('gitly-shell: ${err}')
exit(1)
}
return
}

mut app := new_app()!

app.use(handler: app.before_request)
app.route_use('/:username/:repo_name/pull/:id/files', handler: minify_pr_files_html, after: true)

app.port = get_port(app.config)

app.sync_ssh_authorized_keys() or { app.warn('Failed to sync authorized_keys: ${err}') }

veb.run_at[App, Context](mut app,
port: app.port
family: .ip
Expand Down
17 changes: 17 additions & 0 deletions repo/repo.v
Original file line number Diff line number Diff line change
Expand Up @@ -1195,13 +1195,30 @@ fn (r Repo) git_smart(service string, input string) string {
}

fn (mut app App) generate_clone_url(repo Repo) string {
return app.generate_https_clone_url(repo)
}

fn (mut app App) generate_https_clone_url(repo Repo) string {
hostname := app.config.hostname
username := repo.user_name
repo_name := repo.name

return 'https://${hostname}/${username}/${repo_name}.git'
}

fn (mut app App) generate_ssh_clone_url(repo Repo) string {
hostname := app.config.hostname
username := repo.user_name
repo_name := repo.name
git_user := app.config.ssh.git_user

return '${git_user}@${hostname}:${username}/${repo_name}.git'
}

fn (mut app App) ssh_enabled() bool {
return app.config.ssh.enabled
}

fn first_line(s string) string {
pos := s.index('\n') or { return s }
return s[..pos]
Expand Down
124 changes: 115 additions & 9 deletions ssh/ssh_key.v
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
module main

import os
import time
import crypto.sha256
import encoding.base64

struct SshKey {
id int @[primary; sql: serial]
user_id int @[unique: 'ssh_key']
title string @[unique: 'ssh_key']
key string
created_at time.Time
id int @[primary; sql: serial]
user_id int @[unique: 'ssh_key']
title string @[unique: 'ssh_key']
key string
key_type string
fingerprint string
created_at time.Time
}

fn (mut app App) add_ssh_key(user_id int, title string, key string) ! {
Expand All @@ -19,11 +24,22 @@ fn (mut app App) add_ssh_key(user_id int, title string, key string) ! {
return error('SSH Key already exists')
}

key_type, fingerprint := parse_ssh_public_key(key) or { return error('Invalid SSH key') }

existing := sql app.db {
select from SshKey where fingerprint == fingerprint limit 1
} or { [] }
if existing.len != 0 {
return error('SSH key already exists on another account')
}

new_ssh_key := SshKey{
user_id: user_id
title: title
key: key
created_at: time.now()
user_id: user_id
title: title
key: key
key_type: key_type
fingerprint: fingerprint
created_at: time.now()
}

sql app.db {
Expand All @@ -37,8 +53,98 @@ fn (mut app App) find_ssh_keys(user_id int) []SshKey {
} or { [] }
}

fn (mut app App) find_all_ssh_keys() []SshKey {
return sql app.db {
select from SshKey
} or { [] }
}

fn (mut app App) find_user_by_key_fingerprint(fingerprint string) ?User {
keys := sql app.db {
select from SshKey where fingerprint == fingerprint limit 1
} or { return none }
if keys.len == 0 {
return none
}
return app.get_user_by_id(keys[0].user_id)
}

fn (mut app App) remove_ssh_key(user_id int, id int) ! {
sql app.db {
delete from SshKey where id == id && user_id == user_id
}!
}

fn parse_ssh_public_key(raw string) !(string, string) {
trimmed := raw.trim_space()
parts := trimmed.fields()
if parts.len < 2 {
return error('missing key type and base64-encoded key')
}
key_type := parts[0]
if key_type !in ['ssh-rsa', 'ssh-dss', 'ssh-ed25519', 'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521'] {
return error('unsupported key type: ${key_type}')
}
b64_data := parts[1]
decoded := base64.decode(b64_data)
fingerprint := sha256_ssh_fingerprint(decoded)
return key_type, fingerprint
}

fn sha256_ssh_fingerprint(raw_key []u8) string {
hex_str := sha256.hexhash(raw_key.bytestr())
mut raw_bytes := []u8{len: 32}
for i := 0; i < 32; i++ {
high := hex_char_to_byte(hex_str[i * 2]) or { return '' }
low := hex_char_to_byte(hex_str[i * 2 + 1]) or { return '' }
raw_bytes[i] = (high << 4) | low
}
return 'SHA256:' + base64.encode(raw_bytes).trim_right('=')
}

fn hex_char_to_byte(c u8) !u8 {
return match c {
`0`...`9` { c - `0` }
`a`...`f` { c - `a` + 10 }
else { error('invalid hex character') }
}
}

fn (mut app App) sync_ssh_authorized_keys() ! {
if !app.config.ssh.enabled {
return
}

keys := app.find_all_ssh_keys()
mut entries := []string{}

for key in keys {
app.get_user_by_id(key.user_id) or { continue }
shell_path := app.config.ssh.binary_path
entry := 'command="${shell_path} -ssh-shell ${key.user_id}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ${key.key_type} ${key.key} ${key.title}'
entries << entry
}

os.write_file(app.config.ssh.authorized_keys_path, entries.join('\n') + '\n')!
}

fn parse_ssh_command(cmd string) ?(string, string, string) {
parts := cmd.fields()
if parts.len < 2 {
return none
}
service := parts[0]
repo_arg := parts[1].trim("'").trim_string_right('.git')
if service !in ['git-upload-pack', 'git-receive-pack'] {
return none
}
slash_pos := repo_arg.index('/') or { return none }
username := repo_arg[..slash_pos]
repo_name := repo_arg[slash_pos + 1..]
if username == '' || repo_name == '' {
return none
}
service_type := if service == 'git-receive-pack' { 'receive-pack' } else { 'upload-pack' }
return service_type, username, repo_name
}
8 changes: 4 additions & 4 deletions ssh/ssh_key_routes.v
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,21 @@ pub fn (mut app App) handle_add_ssh_key(mut ctx Context, username string) veb.Re

if is_title_empty {
ctx.error('Title is empty')

return app.user_ssh_keys_new(mut ctx, username)
}

if is_ssh_key_empty {
ctx.error('SSH key is empty')

return app.user_ssh_keys_new(mut ctx, username)
}

app.add_ssh_key(ctx.user.id, title, ssh_key) or {
ctx.error(err.str())

return app.user_ssh_keys_new(mut ctx, username)
}

app.sync_ssh_authorized_keys() or { app.warn('Failed to sync authorized_keys: ${err}') }

return ctx.redirect('/${username}/settings/ssh-keys')
}

Expand All @@ -64,10 +63,11 @@ pub fn (mut app App) handle_remove_ssh_key(mut ctx Context, username string, id
response := api.ApiErrorResponse{
message: 'There was an error while deleting the SSH key'
}

return ctx.json(response)
}

app.sync_ssh_authorized_keys() or { app.warn('Failed to sync authorized_keys: ${err}') }

return ctx.ok('')
}

Expand Down
64 changes: 64 additions & 0 deletions ssh/ssh_shell.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
module main

import os
import config

fn run_ssh_shell(user_id int, conf config.Config) ! {
mut db := connect_db(conf) or { return error('Failed to connect to database: ${err}') }
defer {
db.close() or {}
}

mut app := &App{
db: db
config: conf
}

user := app.get_user_by_id(user_id) or { return error('User not found') }

cmd := os.getenv_opt('SSH_ORIGINAL_COMMAND') or {
println('Hi ${user.username}! You have successfully authenticated via SSH.')
println('Gitly does not provide shell access. Use git commands only.')
return error('no SSH_ORIGINAL_COMMAND')
}

service, repo_owner, repo_name := parse_ssh_command(cmd) or {
println('Unrecognized command: ${cmd}')
return error('invalid SSH command')
}

repo := app.find_repo_by_name_and_username(repo_name, repo_owner) or {
eprintln('Gitly: repository ${repo_owner}/${repo_name} not found')
exit(1)
}

is_write := service == 'receive-pack'

if is_write {
if repo.user_id != user.id {
eprintln('Gitly: access denied - you do not own this repository')
exit(1)
}
} else {
if !repo.is_public && repo.user_id != user.id {
eprintln('Gitly: access denied - repository is private')
exit(1)
}
}

git_path := os.find_abs_path_of_executable('git') or {
eprintln('Gitly: git not found')
exit(1)
}

real_repo_path := os.real_path(repo.git_dir)

mut p := os.new_process(git_path)
p.set_args([service, real_repo_path])
p.run()
p.wait()
exit_code := p.code
p.close()

exit(exit_code)
}
Loading
Loading