├── .ebextensions ├── containercommands.config ├── nginx │ └── conf.d │ │ └── elasticbeanstalk │ │ └── 00_application.conf ├── s3privatekeys.config ├── sshd.config └── users.config ├── .gitignore ├── Buildfile ├── LICENSE ├── Procfile ├── README.md ├── account └── account.go ├── art └── art.go ├── build-web.sh ├── cmd ├── account │ └── account.go ├── authorized-keys │ └── authorized-keys.go ├── build-cmd.sh ├── create │ └── create.go ├── delete │ └── delete.go ├── dump-args │ └── dump-args.go ├── intake │ ├── cell.go │ ├── command.go │ └── intake.go ├── list │ └── list.go ├── no-interactive-login │ └── no-interactive-login.go └── pubkey │ ├── add │ └── add.go │ ├── list │ └── list.go │ └── remove │ └── remove.go ├── db └── db.go ├── go.mod ├── go.sum ├── hooks ├── build-hooks.sh └── pre-receive │ └── pre-receive.go ├── logmill └── logmill.go ├── misc └── misc.go ├── pubkey └── pubkey.go ├── public ├── account.png ├── account_back.png ├── create.png ├── create_back.png ├── create_back2.png ├── delete.png ├── delete_back.png ├── demo.mp4 ├── demo3.mp4 ├── gitern_knot.png ├── gitern_knot_blw.png ├── list.png ├── list_back.png ├── me.jpg ├── me2.jpg ├── pubkey_add.png ├── pubkey_add_back.png ├── pubkey_list.png ├── pubkey_list_back2.png ├── pubkey_remove.png └── pubkey_remove_back.png ├── repo └── repo.go ├── schema.sql ├── scripts └── create-jail.sh ├── sessions.go ├── stripe.go ├── stripehelper └── stripehelper.go ├── views ├── account.tmpl └── index.tmpl └── web.go /.ebextensions/containercommands.config: -------------------------------------------------------------------------------- 1 | container_commands: 2 | 00_attach_accounts: 3 | command: | 4 | INSTANCE_ID=$(/opt/aws/bin/ec2-metadata -i | awk '{print $2}') 5 | aws ec2 attach-volume --region us-west-2 --device /dev/sdf --instance-id ${INSTANCE_ID} --volume-id ${ACCOUNTS_VOL_ID} 6 | test: "! file -LE /dev/sdf" 7 | ignoreErrors: false 8 | 01_attach_accounts_wait: 9 | command: | 10 | COUNTER=0 11 | while [ ! -e /dev/sdf ]; do 12 | if [ $COUNTER -gt 5 ]; then 13 | exit 1 14 | fi 15 | let COUNTER=COUNTER+1 16 | echo Waiting for repos volume to attach 17 | sleep 5 18 | done 19 | test: "! file -LE /dev/sdf" 20 | ignoreErrors: false 21 | 02_mkdir_accounts: 22 | command: "mkdir -p /gitern" 23 | ignoreErrors: false 24 | 03_mount_accounts: 25 | command: "mount /dev/sdf /gitern" 26 | test: "! mountpoint /gitern" 27 | ignoreErrors: false 28 | 29 | 04_create_jail_sh: 30 | command: cd scripts && ./create-jail.sh 31 | ignoreErrors: false 32 | 33 | 05_cmd_build_sh: 34 | command: cd cmd && ./build-cmd.sh 35 | ignoreErrors: false 36 | 37 | 06_hooks_build_sh: 38 | command: cd hooks && ./build-hooks.sh 39 | ignoreErrors: false 40 | -------------------------------------------------------------------------------- /.ebextensions/nginx/conf.d/elasticbeanstalk/00_application.conf: -------------------------------------------------------------------------------- 1 | listen 443 default_server; 2 | location / { 3 | set $redirect 0; 4 | if ($server_port != 443) { 5 | set $redirect 1; 6 | } 7 | if ($http_user_agent ~* "ELB-HealthChecker") { 8 | set $redirect 0; 9 | } 10 | if ($redirect = 1) { 11 | return 301 https://$host$request_uri; 12 | } 13 | 14 | proxy_pass http://127.0.0.1:5000; 15 | proxy_http_version 1.1; 16 | 17 | proxy_set_header Connection $connection_upgrade; 18 | proxy_set_header Upgrade $http_upgrade; 19 | proxy_set_header Host $host; 20 | proxy_set_header X-Real-IP $remote_addr; 21 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 22 | } -------------------------------------------------------------------------------- /.ebextensions/s3privatekeys.config: -------------------------------------------------------------------------------- 1 | Resources: 2 | AWSEBAutoScalingGroup: 3 | Metadata: 4 | AWS::CloudFormation::Authentication: 5 | S3Auth: 6 | type: "s3" 7 | buckets: ["elasticbeanstalk-us-west-2-685922807022"] 8 | roleName: 9 | "Fn::GetOptionSetting": 10 | Namespace: "aws:autoscaling:launchconfiguration" 11 | OptionName: "IamInstanceProfile" 12 | DefaultValue: "aws-elasticbeanstalk-ec2-role" 13 | files: 14 | # Private key 15 | /var/jwt/jwt.key: 16 | mode: "000400" 17 | owner: webapp 18 | group: webapp 19 | # authentication: "S3Auth" 20 | source: https://elasticbeanstalk-us-west-2-685922807022.s3-us-west-2.amazonaws.com/gitern/jwt.key 21 | # Public key 22 | /var/jwt/jwt.pub: 23 | mode: "000400" 24 | owner: webapp 25 | group: webapp 26 | # authentication: "S3Auth" 27 | source: https://elasticbeanstalk-us-west-2-685922807022.s3-us-west-2.amazonaws.com/gitern/jwt.pub 28 | -------------------------------------------------------------------------------- /.ebextensions/sshd.config: -------------------------------------------------------------------------------- 1 | commands: 2 | command 00_sshd_restart: 3 | test: "! diff -q /etc/ssh/{gitern_,}sshd_config" 4 | command: | 5 | mv /etc/ssh/{gitern_,}sshd_config 6 | service sshd restart 7 | ignoreErrors: false 8 | 9 | sources: 10 | /: https://elasticbeanstalk-us-west-2-685922807022.s3-us-west-2.amazonaws.com/gitern/ssh_host_keys.tar.gz 11 | 12 | files: 13 | "/etc/ssh/gitern_sshd_config" : 14 | mode: "000600" 15 | owner: root 16 | group: root 17 | content: | 18 | # $OpenBSD: sshd_config,v 1.100 2016/08/15 12:32:04 naddy Exp $ 19 | 20 | # This is the sshd server system-wide configuration file. See 21 | # sshd_config(5) for more information. 22 | 23 | # This sshd was compiled with PATH=/usr/local/bin:/bin:/usr/bin 24 | 25 | # The strategy used for options in the default sshd_config shipped with 26 | # OpenSSH is to specify options with their default value where 27 | # possible, but leave them commented. Uncommented options override the 28 | # default value. 29 | 30 | # If you want to change the port on a SELinux system, you have to tell 31 | # SELinux about this change. 32 | # semanage port -a -t ssh_port_t -p tcp #PORTNUMBER 33 | # 34 | #Port 22 35 | #AddressFamily any 36 | #ListenAddress 0.0.0.0 37 | #ListenAddress :: 38 | 39 | HostKey /etc/ssh/ssh_host_rsa_key 40 | #HostKey /etc/ssh/ssh_host_dsa_key 41 | HostKey /etc/ssh/ssh_host_ecdsa_key 42 | HostKey /etc/ssh/ssh_host_ed25519_key 43 | 44 | # Ciphers and keying 45 | #RekeyLimit default none 46 | 47 | # Logging 48 | #SyslogFacility AUTH 49 | SyslogFacility AUTHPRIV 50 | #LogLevel INFO 51 | 52 | # Authentication: 53 | 54 | #LoginGraceTime 2m 55 | #PermitRootLogin yes 56 | # Only allow root to run commands over ssh, no shell 57 | PermitRootLogin forced-commands-only 58 | #StrictModes yes 59 | #MaxAuthTries 6 60 | #MaxSessions 10 61 | 62 | #PubkeyAuthentication yes 63 | 64 | # The default is to check both .ssh/authorized_keys and .ssh/authorized_keys2 65 | # but this is overridden so installations will only check .ssh/authorized_keys 66 | AuthorizedKeysFile .ssh/authorized_keys 67 | 68 | #AuthorizedPrincipalsFile none 69 | 70 | #AuthorizedKeysCommand none 71 | #AuthorizedKeysCommandUser nobody 72 | 73 | # For this to work you will also need host keys in /etc/ssh/ssh_known_hosts 74 | #HostbasedAuthentication no 75 | # Change to yes if you don't trust ~/.ssh/known_hosts for 76 | # HostbasedAuthentication 77 | #IgnoreUserKnownHosts no 78 | # Don't read the user's ~/.rhosts and ~/.shosts files 79 | #IgnoreRhosts yes 80 | 81 | # EC2 uses keys for remote access 82 | PasswordAuthentication no 83 | #PermitEmptyPasswords no 84 | 85 | # Change to no to disable s/key passwords 86 | #ChallengeResponseAuthentication yes 87 | ChallengeResponseAuthentication no 88 | 89 | # Kerberos options 90 | #KerberosAuthentication no 91 | #KerberosOrLocalPasswd yes 92 | #KerberosTicketCleanup yes 93 | #KerberosGetAFSToken no 94 | #KerberosUseKuserok yes 95 | 96 | # GSSAPI options 97 | #GSSAPIAuthentication no 98 | #GSSAPICleanupCredentials yes 99 | #GSSAPIStrictAcceptorCheck yes 100 | #GSSAPIKeyExchange no 101 | #GSSAPIEnablek5users no 102 | 103 | # Set this to 'yes' to enable PAM authentication, account processing, 104 | # and session processing. If this is enabled, PAM authentication will 105 | # be allowed through the ChallengeResponseAuthentication and 106 | # PasswordAuthentication. Depending on your PAM configuration, 107 | # PAM authentication via ChallengeResponseAuthentication may bypass 108 | # the setting of "PermitRootLogin without-password". 109 | # If you just want the PAM account and session checks to run without 110 | # PAM authentication, then enable this but set PasswordAuthentication 111 | # and ChallengeResponseAuthentication to 'no'. 112 | # WARNING: 'UsePAM no' is not supported in Amazon Linux AMI and may cause several 113 | # problems. 114 | UsePAM yes 115 | 116 | #AllowAgentForwarding yes 117 | #AllowTcpForwarding yes 118 | #GatewayPorts no 119 | X11Forwarding yes 120 | #X11DisplayOffset 10 121 | #X11UseLocalhost yes 122 | #PermitTTY yes 123 | #PrintMotd yes 124 | PrintLastLog yes 125 | #TCPKeepAlive yes 126 | #UseLogin no 127 | UsePrivilegeSeparation sandbox 128 | #PermitUserEnvironment no 129 | #Compression delayed 130 | #ClientAliveInterval 0 131 | #ClientAliveCountMax 3 132 | #ShowPatchLevel no 133 | #UseDNS yes 134 | #PidFile /var/run/sshd.pid 135 | #MaxStartups 10:30:100 136 | #PermitTunnel no 137 | #ChrootDirectory none 138 | #VersionAddendum none 139 | 140 | # no default banner path 141 | #Banner none 142 | 143 | # Accept locale-related environment variables 144 | AcceptEnv LANG LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES 145 | AcceptEnv LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT 146 | AcceptEnv LC_IDENTIFICATION LC_ALL LANGUAGE 147 | AcceptEnv XMODIFIERS 148 | 149 | # override default of no subsystems 150 | Subsystem sftp /usr/libexec/openssh/sftp-server 151 | 152 | # Example of overriding settings on a per-user basis 153 | #Match User anoncvs 154 | # X11Forwarding no 155 | # AllowTcpForwarding no 156 | # PermitTTY no 157 | # ForceCommand cvs server 158 | 159 | UseDNS no 160 | PermitUserEnvironment yes 161 | LogLevel INFO 162 | Match User git 163 | AuthorizedKeysCommand /usr/bin/gitern-authorized-keys %f %t %k 164 | AuthorizedKeysCommandUser authorized-keys-command 165 | ForceCommand gitern-intake 166 | AcceptEnv none 167 | ChrootDirectory /jail -------------------------------------------------------------------------------- /.ebextensions/users.config: -------------------------------------------------------------------------------- 1 | groups: 2 | git: 3 | gid: "556" 4 | authorized-keys-command: 5 | gid: "889" 6 | serf: 7 | gid: "445" 8 | 9 | commands: 10 | command 00_add_user_git: 11 | test: test ! "`id -u git 2> /dev/null`" 12 | command: useradd -d / -g git -u 555 git -s /usr/bin/git-shell 13 | ignoreErrors: false 14 | command 01_add_user_authorized_keys_command: 15 | test: test ! "`id -u authorized-keys-command 2> /dev/null`" 16 | command: useradd -d /home/authorized-keys-command -g authorized-keys-command -u 888 authorized-keys-command -s /sbin/nologin 17 | ignoreErrors: false 18 | command 02_add_user_serf: 19 | test: test ! "`id -u serf 2> /dev/null`" 20 | command: useradd -d /home/serf -g serf -u 444 serf -s /sbin/nologin 21 | ignoreErrors: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | .env 3 | tmp 4 | bin 5 | .env 6 | 7 | .DS_Store 8 | 9 | # jwt 10 | *.key 11 | *.pub 12 | 13 | # Elastic Beanstalk Files 14 | .elasticbeanstalk/* 15 | !.elasticbeanstalk/*.cfg.yml 16 | !.elasticbeanstalk/*.global.yml 17 | -------------------------------------------------------------------------------- /Buildfile: -------------------------------------------------------------------------------- 1 | make: ./build-web.sh -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Keyan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./gitern-web -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is the source for https://gitern.com ... in summary it's 2 | 1. a multitenant git host 3 | 2. that uses ssh keys for auth exclusively 4 | 3. and has a command-line ui 5 | 6 | I'm sharing it hoping it might be able to help someone build a git host on nostr. This isn't particularly relevant to be honest but does 7 | 1. have novel auth and 8 | 2. is very minimal 9 | 10 | I will one day describe more about how it works (especially if someone bothers to ask), but this gist is: 11 | - lots of SSHD hacks 12 | - users are progressively chroot/jailed into isolated parts of the filesytem upon authentication with different sets of commands available 13 | 14 | As a starting point, this is where auth begins (this is from the sshd config): 15 | 16 | ```txt 17 | Match User git 18 | AuthorizedKeysCommand /usr/bin/gitern-authorized-keys %f %t %k 19 | AuthorizedKeysCommandUser authorized-keys-command 20 | ForceCommand gitern-intake 21 | AcceptEnv none 22 | ChrootDirectory /jail 23 | ``` 24 | 25 | They then get forced through `gitern-intake` whose source is `/cmd/intake/intake.go`. 26 | 27 | That should be enough for someone curious to start. Huzzah! 28 | 29 | Extra credit 30 | --------------- 31 | I also wrote a remote helper for gitern that end to end encrypts git repos: https://github.com/huumn/git-remote-gitern ... It's pretty inefficient and naive but it kind of shows how you might do something ***really freaking weird*** with git that runs exclusively on the client (no special git server required). 32 | 33 | 34 | -------------------------------------------------------------------------------- /account/account.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "fmt" 5 | "gitern/db" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | const ACCOUNTS_PATH = "/accounts" 12 | 13 | type Account struct { 14 | Name string 15 | Status string 16 | Quota int64 17 | } 18 | 19 | // path before being chrooted 20 | func (account Account) Path() (string, error) { 21 | return AccountPath(account.Name) 22 | } 23 | 24 | // path after being chroot'd 25 | func (account Account) ReposPath() string { 26 | return filepath.Join("/", account.Name) 27 | } 28 | 29 | const MEGABYTE = 1024 * 1024 30 | 31 | func Accounts() ([]Account, error) { 32 | authAccounts := os.Getenv("ACCOUNT") 33 | if authAccounts == "" { 34 | return nil, fmt.Errorf("You don't administer any accounts") 35 | } 36 | 37 | var accounts []Account 38 | authAccountsSplit := strings.Split(authAccounts, ",") 39 | for _, authAccountStr := range authAccountsSplit { 40 | var account Account 41 | _, err := fmt.Sscan(authAccountStr, &account.Name, &account.Status) 42 | if err != nil { 43 | return nil, err 44 | } 45 | if account.Status == "paid" { 46 | account.Quota = 0 47 | } else { 48 | // TODO: take from env 49 | account.Quota = 25 * MEGABYTE 50 | } 51 | accounts = append(accounts, account) 52 | } 53 | 54 | return accounts, nil 55 | } 56 | 57 | func AccountNames() ([]string, error) { 58 | accounts, err := Accounts() 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | var names []string 64 | for _, account := range accounts { 65 | names = append(names, account.Name) 66 | } 67 | 68 | return names, nil 69 | } 70 | 71 | func CheckAccess(accountName string) error { 72 | _, err := GetAuthAccount(accountName) 73 | return err 74 | } 75 | 76 | func GetAuthAccount(accountName string) (Account, error) { 77 | authAccounts, err := Accounts() 78 | if err != nil { 79 | return Account{}, err 80 | } 81 | 82 | for _, a := range authAccounts { 83 | if a.Name == accountName { 84 | return a, nil 85 | } 86 | } 87 | 88 | return Account{}, fmt.Errorf("Unauthorized for account %s", accountName) 89 | } 90 | 91 | func AccountPath(accountName string) (string, error) { 92 | path := filepath.Join(ACCOUNTS_PATH, accountName) 93 | _, err := os.Stat(path) 94 | if err != nil { 95 | return "", err 96 | } 97 | 98 | return path, nil 99 | } 100 | 101 | func CreateSession(accountName string, fp string) (string, error) { 102 | err := CheckAccess(accountName) 103 | if err != nil { 104 | return "", err 105 | } 106 | 107 | row := db.Conn.QueryRow(`SELECT create_session($1, $2)`, accountName, fp) 108 | 109 | var id string 110 | if err := row.Scan(&id); err != nil { 111 | return "", err 112 | } 113 | 114 | return id, nil 115 | } 116 | -------------------------------------------------------------------------------- /art/art.go: -------------------------------------------------------------------------------- 1 | package art 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | type Art []string 11 | 12 | var Magic Art = Art{ 13 | ` . _ `, 14 | ` |\ | | `, 15 | ` _/_\_ 0X0 `, 16 | ` ,_\-/_ | `, 17 | ` / | | \_ | `, 18 | ` \ \| |\__(/ `, 19 | ` /\/ \ | `, 20 | ` / \ | `, 21 | ` _.' \ | `, 22 | ` '------.--.-' | `, 23 | } 24 | 25 | var Scroll Art = Art{ 26 | ` _____________ `, 27 | ` (_\ \ `, 28 | ` | | `, 29 | ` | | `, 30 | ` | granted | `, 31 | ` | | `, 32 | ` |___________| `, 33 | ` @)____________) `, 34 | } 35 | 36 | var Skull Art = Art{ 37 | ` .-----. `, 38 | ` /, ,\ `, 39 | ` | )() ()( | `, 40 | ` {}_ (/_ ^ _\) _{} `, 41 | ` "=| IIIII |=" `, 42 | ` _.=\_____/=._ `, 43 | ` {}" "{} `, 44 | } 45 | 46 | var Scales Art = Art{ 47 | ` II `, 48 | ` .______()______. `, 49 | ` /|\ || /|\ `, 50 | ` / | \ || / | \ `, 51 | ` (__|__) || (__|__) `, 52 | ` || `, 53 | ` || `, 54 | ` __||__ `, 55 | ` /______\ `, 56 | } 57 | 58 | var Fool Art = Art{ 59 | ` __ `, 60 | ` _ //\( __ `, 61 | " //`\\/ | @/ \\\\ ", 62 | ` ||. \_|_/ .|| `, 63 | ` )/|_______|\( `, 64 | ` @ | ^ ^ | @ `, 65 | ` \ o o / `, 66 | ` ( @ ) `, 67 | " \\ `--'/ ", 68 | " `---' ", 69 | } 70 | 71 | var Noose Art = Art{ 72 | ` || `, 73 | ` || `, 74 | ` (-_) `, 75 | ` (_-) `, 76 | ` // \\ `, 77 | ` // \\ `, 78 | ` // \\ `, 79 | ` || || `, 80 | ` \\ // `, 81 | " ``===='' ", 82 | } 83 | 84 | var Sun Art = Art{ 85 | ` . `, 86 | ` '. | .' `, 87 | `_ \ .-''-./ _ `, 88 | ` "-._/ \_.-" `, 89 | `- - | | - - `, 90 | ` _."\ /"._ `, 91 | `-" / '-..-' \ "- `, 92 | " .' | `. ", 93 | ` ' `, 94 | } 95 | 96 | var Tower Art = Art{ 97 | ` | `, 98 | ` A `, 99 | ` / \ `, 100 | ` ||| \/ `, 101 | ` ||| ^. `, 102 | ` ||| `, 103 | ` |||.( `, 104 | ` .(||/\%\ `, 105 | ` /%/\(%(%)) `, 106 | } 107 | 108 | func (art Art) Fprint(w io.Writer, msg ...string) { 109 | nlines := len(art) 110 | if len(msg) > nlines { 111 | nlines = len(msg) 112 | } else { 113 | // take the difference, divide it by two, that's how many blank lines to 114 | // prepend to msg 115 | msg = append(make([]string, (nlines-len(msg))/2), msg...) 116 | } 117 | 118 | for i := 0; i < nlines; i++ { 119 | var prefix, message string 120 | if i < len(art) { 121 | prefix = art[i] 122 | } else { 123 | prefix = strings.Repeat(" ", len(art[0])) 124 | } 125 | if i < len(msg) { 126 | message = msg[i] 127 | } 128 | fmt.Fprintln(w, prefix, message) 129 | } 130 | } 131 | 132 | func (art Art) Print(msg ...string) { 133 | art.Fprint(os.Stdout, msg...) 134 | } 135 | 136 | func (art Art) Fatal(msg ...string) { 137 | art.Fprint(os.Stderr, msg...) 138 | os.Exit(1) 139 | } 140 | -------------------------------------------------------------------------------- /build-web.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | go build -o gitern-web -------------------------------------------------------------------------------- /cmd/account/account.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "gitern/account" 6 | "gitern/art" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | func main() { 12 | if len(os.Args) > 2 { 13 | art.Fool.Fatal("Usage: gitern-account []") 14 | } 15 | 16 | fp := os.Getenv("FP") 17 | var accname string 18 | 19 | if len(os.Args) == 1 { 20 | accNames, err := account.AccountNames() 21 | if err != nil { 22 | art.Tower.Fatal(err.Error()) 23 | } 24 | if len(accNames) > 1 { 25 | art.Fool.Fatal("Must specify account name.", strings.Join(accNames, " or ")) 26 | } 27 | 28 | accname = accNames[0] 29 | } else { 30 | accname = os.Args[1] 31 | } 32 | 33 | id, err := account.CreateSession(accname, fp) 34 | if err != nil { 35 | art.Tower.Fatal(err.Error()) 36 | } 37 | 38 | art.Sun.Print( 39 | fmt.Sprintf("View account %s at gitern.com/%s in a web browser.", accname, id), 40 | "This link expires in 5 minutes and is only good for one use.") 41 | } 42 | -------------------------------------------------------------------------------- /cmd/authorized-keys/authorized-keys.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "gitern/db" 10 | ) 11 | 12 | const FP_PREFIX = "SHA256:" 13 | 14 | func main() { 15 | if len(os.Args) != 4 { 16 | log.Fatalln("Usage: gitern-authorized-keys ") 17 | } 18 | 19 | fingerprint := os.Args[1] 20 | if !strings.HasPrefix(fingerprint, FP_PREFIX) { 21 | log.Fatalln("Fingerprint must begin with " + FP_PREFIX) 22 | } 23 | 24 | fingerprint = strings.TrimPrefix(fingerprint, FP_PREFIX) 25 | 26 | // e.g. array_to_string: keyan paid,k00b unpaid,kk failed 27 | row := db.Conn.QueryRow(`SELECT array_to_string(array_agg(concat(name, ' ', COALESCE(text(stripe_status),'unpaid'))), ','), 28 | fingerprint, keytype, pubkey 29 | FROM pubkeys 30 | JOIN accounts_pubkeys 31 | ON fingerprint = pubkey_fingerprint 32 | JOIN accounts 33 | ON name = account_name 34 | WHERE fingerprint = $1 35 | GROUP BY fingerprint`, fingerprint) 36 | 37 | var account, fp, keytype, pubkey string 38 | if err := row.Scan(&account, &fp, &keytype, &pubkey); err != nil { 39 | fmt.Printf("environment=\"FP=%s\",restrict %s %s\n", fingerprint, os.Args[2], os.Args[3]) 40 | return 41 | } 42 | 43 | fmt.Printf("environment=\"ACCOUNT=%s\",environment=\"FP=%s\",restrict %s %s\n", account, fp, keytype, pubkey) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/build-cmd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Elastic Beanstalk provides these environment variables at this build stage and 3 | # directly to webapp, so we build them into the binary 4 | LDFLAGS="-X gitern/stripehelper.STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} -X gitern/db.RDS_DB_NAME=${RDS_DB_NAME} -X gitern/db.RDS_USERNAME=${RDS_USERNAME} -X gitern/db.RDS_PASSWORD=${RDS_PASSWORD} -X gitern/db.RDS_HOSTNAME=${RDS_HOSTNAME} -X gitern/db.RDS_PORT=${RDS_PORT}" 5 | PREFIX=gitern 6 | # min and med security 7 | CMDS_MIN=/jail/git-shell-commands 8 | CMDS_MED=/jail/git-shell-commands-MEDSEC 9 | 10 | ## remove old commands from commissary just in case the names changed 11 | # rm -rf ${CMDS_MIN}/${PREFIX}-* ${CMDS_MED}/${PREFIX}-* 12 | 13 | # don't allow serfs to execute modifying commands 14 | lordsOnly () { 15 | chmod 750 $1 16 | chown root:git $1 17 | } 18 | 19 | ## authorized-keys 20 | go build -ldflags "${LDFLAGS}" -o /usr/bin/${PREFIX}-authorized-keys authorized-keys/* 21 | 22 | ## dump-args 23 | go build -ldflags "${LDFLAGS}" -o ${CMDS_MED}/${PREFIX}-dump-args dump-args/* 24 | lordsOnly ${CMDS_MED}/${PREFIX}-dump-args 25 | 26 | ## create 27 | go build -ldflags "${LDFLAGS}" -o ${CMDS_MED}/${PREFIX}-create create/* 28 | lordsOnly ${CMDS_MED}/${PREFIX}-create 29 | 30 | ## list is in both LVL1 (listing all accounts) and LVL2 31 | go build -ldflags "${LDFLAGS}" -o ${CMDS_MED}/${PREFIX}-list list/* 32 | go build -ldflags "${LDFLAGS}" -o ${CMDS_MIN}/${PREFIX}-list list/* 33 | 34 | ## account is in both LVL1 (one unspecified account) and LVL2 (a specified account) 35 | go build -ldflags "${LDFLAGS}" -o ${CMDS_MED}/${PREFIX}-account account/* 36 | go build -ldflags "${LDFLAGS}" -o ${CMDS_MIN}/${PREFIX}-account account/* 37 | lordsOnly ${CMDS_MIN}/${PREFIX}-account 38 | lordsOnly ${CMDS_MED}/${PREFIX}-account 39 | 40 | ## delete 41 | go build -ldflags "${LDFLAGS}" -o ${CMDS_MED}/${PREFIX}-delete delete/* 42 | lordsOnly ${CMDS_MED}/${PREFIX}-delete 43 | 44 | ## no-interactive-login 45 | go build -o ${CMDS_MIN}/no-interactive-login no-interactive-login/* 46 | 47 | ## intake-command 48 | INTAKE_COMMAND=${CMDS_MIN}/${PREFIX}-intake 49 | go build -ldflags "${LDFLAGS}" -o ${INTAKE_COMMAND} intake/* 50 | # setuid: intake command sets up an account cell and jails them if needed 51 | chmod 4755 ${INTAKE_COMMAND} 52 | 53 | ## pubkey add 54 | go build -ldflags "${LDFLAGS}" -o ${CMDS_MIN}/${PREFIX}-pubkey-add pubkey/add/* 55 | lordsOnly ${CMDS_MIN}/${PREFIX}-pubkey-add 56 | 57 | ## pubkey list 58 | go build -ldflags "${LDFLAGS}" -o ${CMDS_MIN}/${PREFIX}-pubkey-list pubkey/list/* 59 | lordsOnly ${CMDS_MIN}/${PREFIX}-pubkey-list 60 | 61 | ## pubkey remove 62 | go build -ldflags "${LDFLAGS}" -o ${CMDS_MIN}/${PREFIX}-pubkey-remove pubkey/remove/* 63 | lordsOnly ${CMDS_MIN}/${PREFIX}-pubkey-remove -------------------------------------------------------------------------------- /cmd/create/create.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "gitern/account" 7 | "gitern/art" 8 | "gitern/misc" 9 | "gitern/repo" 10 | "os" 11 | "os/exec" 12 | ) 13 | 14 | func main() { 15 | var public bool 16 | flag.BoolVar(&public, "public", false, "make public") 17 | flag.Parse() 18 | 19 | if len(flag.Args()) == 0 { 20 | art.Fool.Fatal("Usage: gitern-create [--public] ") 21 | } 22 | 23 | free, err := misc.EnvToLimit("FREE") 24 | if err != nil { 25 | art.Tower.Fatal("free env", err.Error()) 26 | } 27 | if free <= 0 { 28 | accname := os.Getenv("ACTIVE_ACCOUNT") 29 | fp := os.Getenv("FP") 30 | 31 | id, err := account.CreateSession(accname, fp) 32 | if err != nil { 33 | art.Tower.Fatal("create session", err.Error()) 34 | } 35 | 36 | art.Scales.Fatal( 37 | fmt.Sprintf("Your account quota for '%s' of 25MB is full.", accname), 38 | fmt.Sprintf("Get unlimited storage on gitern; add a payment method to '%s.'", accname), 39 | fmt.Sprintf("Visit gitern.com/%s in a web browser.", id), 40 | ) 41 | } 42 | 43 | path := repo.CanonicalizeRepoPath(flag.Arg(0)) 44 | err = repo.MakePath(path, public) 45 | if err != nil { 46 | art.Fool.Fatal(path, err.Error()) 47 | } 48 | 49 | err = os.Chdir(path) 50 | if err != nil { 51 | art.Tower.Fatal(path, "chdir", err.Error()) 52 | } 53 | 54 | gitInit := exec.Command("git", "init", "--bare") 55 | if err := gitInit.Run(); err != nil { 56 | art.Tower.Fatal(path, "exec git init --bare", err.Error()) 57 | } 58 | 59 | repoPath := "git@gitern.com:" + path 60 | art.Magic.Print( 61 | fmt.Sprintf("Initialized empty%s Git repository at %s", func() string { 62 | if public { 63 | return " public" 64 | } 65 | return "" 66 | }(), repoPath), 67 | "To add as remote:", 68 | fmt.Sprintf("\tgit remote add origin %s", repoPath), 69 | "To clone the empty repository:", 70 | fmt.Sprintf("\tgit clone %s", repoPath), 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /cmd/delete/delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gitern/art" 5 | "gitern/repo" 6 | "os" 7 | ) 8 | 9 | func main() { 10 | if len(os.Args) != 2 { 11 | art.Fool.Fatal("Usage: gitern-delete ") 12 | } 13 | 14 | path := repo.CanonicalizeRepoPath(os.Args[1]) 15 | 16 | if !repo.PathExists(path) { 17 | art.Fool.Fatal(path, "Does not exist") 18 | } 19 | 20 | err := os.RemoveAll(path) 21 | if err != nil { 22 | art.Tower.Fatal(path, "remove", err.Error()) 23 | } 24 | 25 | repo.CleanParents(path) 26 | 27 | art.Skull.Print(path, "deleted") 28 | } 29 | -------------------------------------------------------------------------------- /cmd/dump-args/dump-args.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "gitern/misc" 7 | "log" 8 | "os" 9 | "syscall" 10 | ) 11 | 12 | func main() { 13 | scanner := bufio.NewScanner(os.Stdin) 14 | for scanner.Scan() { 15 | fmt.Println(scanner.Text()) 16 | } 17 | if err := scanner.Err(); err != nil { 18 | log.Println(err) 19 | } 20 | 21 | fmt.Printf("uid (%d) gid (%d)\n", syscall.Getuid(), syscall.Getgid()) 22 | fmt.Printf("euid (%d) egid (%d)\n", syscall.Geteuid(), syscall.Getegid()) 23 | fmt.Println("so.Args: ", os.Args) 24 | fmt.Println("so.Environ: ", os.Environ()) 25 | usage, err := misc.DiskUsage("/") 26 | fmt.Println("DiskUsage: ", usage, err) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/intake/cell.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "syscall" 8 | ) 9 | 10 | func mountpoint(src, accountPath string) string { 11 | IGNORE := []string{ 12 | "git-shell-commands", 13 | "accounts", 14 | } 15 | RENAME := map[string]string{ 16 | "git-shell-commands-MEDSEC": "git-shell-commands", 17 | } 18 | 19 | basename := filepath.Base(src) 20 | for _, name := range IGNORE { 21 | if name == basename { 22 | return "" 23 | } 24 | } 25 | 26 | name, ok := RENAME[basename] 27 | if ok { 28 | basename = name 29 | } 30 | return filepath.Join(accountPath, basename) 31 | } 32 | 33 | // commissary is at the root of force-command's jail 34 | func mountCommissary(accountPath string) error { 35 | files, err := ioutil.ReadDir("/") 36 | if err != nil { 37 | return err 38 | } 39 | 40 | for _, file := range files { 41 | src := filepath.Join("/", file.Name()) 42 | dest := mountpoint(src, accountPath) 43 | if dest == "" { 44 | continue 45 | } 46 | 47 | err = createDir(dest, 0, 0) 48 | if err != nil { 49 | return err 50 | } 51 | err = mount(src, dest) 52 | if err != nil { 53 | return err 54 | } 55 | } 56 | return nil 57 | } 58 | 59 | // create directory with account name owned by git 60 | // which will host the account's repos 61 | // e.g. // 62 | // when user is chrooted in / their 63 | // repos have the path /some/repo/path.git 64 | func createRepoDir(accountPath string) error { 65 | gid := os.Getgid() 66 | dirPath := filepath.Join(accountPath, filepath.Base(accountPath)) 67 | return createDir(dirPath, realUserId, gid) 68 | } 69 | 70 | func createCell(accountPath string) error { 71 | err := mountCommissary(accountPath) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | err = createRepoDir(accountPath) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | return nil 82 | } 83 | 84 | func createDir(path string, uid, gid int) error { 85 | return doPriveleged(func() error { 86 | err := os.Mkdir(path, 0755) 87 | if err != nil { 88 | if os.IsExist(err) { 89 | return nil 90 | } 91 | return err 92 | } 93 | 94 | if uid != 0 || gid != 0 { 95 | return os.Chown(path, uid, gid) 96 | } 97 | 98 | return nil 99 | }) 100 | } 101 | 102 | func mount(src, dest string) error { 103 | files, err := ioutil.ReadDir(dest) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | // we assume that all commissary dirs have contents, so 109 | // we use that to decide whether to mount them or not 110 | if len(files) == 0 { 111 | return doPriveleged(func() error { 112 | // mount readonly, don't allow set uid 113 | var opts uintptr = syscall.MS_BIND | syscall.MS_RDONLY | syscall.MS_NOSUID 114 | return syscall.Mount(src, dest, "none", opts, "") 115 | }) 116 | } 117 | 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /cmd/intake/command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "syscall" 7 | ) 8 | 9 | type command struct { 10 | name string 11 | args []string 12 | full string 13 | } 14 | 15 | func newCommand(cmdStr string) command { 16 | // log.Printf("CMD %s UID %d EUID %d GID %d EGID %d\n", cmdStr, syscall.Getuid(), 17 | // syscall.Geteuid(), syscall.Getgid(), syscall.Getegid()) 18 | 19 | fields := strings.Fields(cmdStr) 20 | if len(fields) > 0 { 21 | return command{fields[0], fields[1:], cmdStr} 22 | } 23 | return command{} 24 | } 25 | 26 | func (c command) serfsAllowed() bool { 27 | SERF_CMDS := map[string]int{ 28 | "gitern-list": 1, 29 | "git-upload-pack": 5, 30 | "git-upload-archive": 1, 31 | } 32 | 33 | nargs, ok := SERF_CMDS[c.name] 34 | return ok && len(c.args) <= nargs 35 | } 36 | 37 | func (c command) noAccountArgNeeded() bool { 38 | MIN_SEC_CMDS := map[string]int{ 39 | "gitern-pubkey-add": 1, 40 | "gitern-pubkey-remove": 2, 41 | "gitern-pubkey-list": 2, 42 | "gitern-list": 0, 43 | "": 0, 44 | } 45 | 46 | nargs, ok := MIN_SEC_CMDS[c.name] 47 | return ok && len(c.args) <= nargs 48 | } 49 | 50 | func (c command) allowedInMinSecurity() bool { 51 | MIN_SEC_CMDS := map[string]int{ 52 | "gitern-pubkey-add": 1, 53 | "gitern-pubkey-remove": 2, 54 | "gitern-pubkey-list": 2, 55 | "gitern-list": 0, 56 | "": 0, 57 | } 58 | 59 | nargs, ok := MIN_SEC_CMDS[c.name] 60 | return ok && len(c.args) <= nargs 61 | } 62 | 63 | func (c command) accountViaPath() bool { 64 | return c.name == "gitern-create" || 65 | c.name == "gitern-delete" || 66 | c.name == "gitern-list" || 67 | c.name == "git-receive-pack" || 68 | c.name == "git-upload-pack" || 69 | c.name == "git-upload-archive" 70 | } 71 | 72 | // we assume the last arg (if there is one) is the path 73 | func (c command) accountTarget() string { 74 | trimmedPath := strings.TrimPrefix(strings.TrimPrefix(c.path(), "'"), 75 | string(os.PathSeparator)) 76 | return strings.Split(trimmedPath, string(os.PathSeparator))[0] 77 | } 78 | 79 | func (c command) path() string { 80 | if len(c.args) < 1 { 81 | return "" 82 | } 83 | return c.args[len(c.args)-1] 84 | } 85 | 86 | func (c command) exec() error { 87 | // log.Printf("EXEC as UID %d EUID %d GID %d EGID %d\n", syscall.Getuid(), 88 | // syscall.Geteuid(), syscall.Getgid(), syscall.Getegid()) 89 | 90 | // exec SSH_ORIGINAL_COMMAND 91 | argv := []string{"/usr/bin/git-shell"} 92 | if c.full != "" { 93 | argv = append(argv, "-c", c.full) 94 | } 95 | err := syscall.Exec(argv[0], argv, os.Environ()) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /cmd/intake/intake.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gitern/account" 5 | "gitern/art" 6 | "gitern/misc" 7 | "os" 8 | "strconv" 9 | "syscall" 10 | ) 11 | 12 | // force command 13 | // 1. mounts all deps in the account dir ACCOUNT 14 | // 2. chroots us to the account dir ACCOUNT (if required) 15 | // 3. execs SSH_ORIGINAL_COMMAND 16 | 17 | // https://www.gnu.org/software/libc/manual/html_node/Setuid-Program-Example.html 18 | // https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch01s03.html 19 | var ( 20 | realUserId int 21 | effectiveUserId int 22 | ) 23 | 24 | const ( 25 | SerfUID = 444 26 | SerfGID = 445 27 | ) 28 | 29 | func unprivelege() { 30 | err := syscall.Setreuid(effectiveUserId, realUserId) 31 | if err != nil { 32 | art.Tower.Fatal("unprivelege", err.Error()) 33 | } 34 | } 35 | 36 | func privelege() { 37 | err := syscall.Setreuid(realUserId, effectiveUserId) 38 | if err != nil { 39 | art.Tower.Fatal("privelege", err.Error()) 40 | } 41 | } 42 | 43 | func bondSerf() { 44 | privelege() 45 | 46 | err := syscall.Setgroups([]int{SerfGID}) // remove sup group priveleges too 47 | if err != nil { 48 | art.Tower.Fatal("bonding serf (sup groups)", err.Error()) 49 | } 50 | 51 | // must set group before user id 52 | err = syscall.Setgid(SerfGID) 53 | if err != nil { 54 | art.Tower.Fatal("bonding serf (gid)", err.Error()) 55 | } 56 | 57 | err = syscall.Setuid(SerfUID) 58 | if err != nil { 59 | art.Tower.Fatal("bonding serf (uid)", err.Error()) 60 | } 61 | } 62 | 63 | func doPriveleged(fn func() error) error { 64 | privelege() 65 | err := fn() 66 | unprivelege() 67 | return err 68 | } 69 | 70 | func lockup(accPath string) error { 71 | err := doPriveleged(func() error { 72 | return syscall.Chroot(accPath) 73 | }) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | return os.Chdir("/") 79 | } 80 | 81 | func main() { 82 | realUserId = syscall.Getuid() 83 | effectiveUserId = syscall.Geteuid() 84 | syscall.Setgroups([]int{syscall.Getgid()}) // remove sup group priveleges too 85 | unprivelege() 86 | 87 | command := newCommand(os.Getenv("SSH_ORIGINAL_COMMAND")) 88 | 89 | if command.allowedInMinSecurity() { 90 | err := command.exec() 91 | art.Tower.Fatal("exec", err.Error()) // if we get here, error 92 | } 93 | 94 | accName := command.accountTarget() 95 | if accName == "" { 96 | if command.accountViaPath() { 97 | art.Fool.Fatal("you must specify a repo path") 98 | } 99 | 100 | art.Fool.Fatal("you must specify an account name") 101 | } 102 | 103 | accPath, err := account.AccountPath(accName) 104 | if err != nil { 105 | art.Fool.Fatal("no such account", accName) 106 | } 107 | 108 | err = createCell(accPath) 109 | if err != nil { 110 | art.Tower.Fatal("create cell", err.Error()) 111 | } 112 | 113 | err = lockup(accPath) 114 | if err != nil { 115 | art.Tower.Fatal("lockup", err.Error()) 116 | } 117 | 118 | err = account.CheckAccess(accName) 119 | if err == nil { 120 | authAcc, err := account.GetAuthAccount(accName) 121 | if err != nil { 122 | art.Tower.Fatal("could not get lord's account", err.Error()) 123 | } 124 | 125 | if authAcc.Quota != 0 { 126 | repoPath := authAcc.ReposPath() 127 | du, err := misc.DiskUsage(repoPath) 128 | if err != nil { 129 | art.Tower.Fatal("disk usage", err.Error()) 130 | } 131 | os.Setenv("QUOTA", strconv.FormatInt(authAcc.Quota, 10)) 132 | os.Setenv("FREE", strconv.FormatInt(authAcc.Quota-du, 10)) 133 | } 134 | os.Setenv("ACTIVE_ACCOUNT", authAcc.Name) 135 | } else { 136 | // are we allowed to execute the command? 137 | if !command.serfsAllowed() { 138 | art.Fool.Fatal(err.Error()) 139 | } 140 | 141 | // give them the lowest perms 142 | bondSerf() 143 | } 144 | 145 | err = command.exec() 146 | art.Tower.Fatal("exec", err.Error()) // if we get here, error 147 | } 148 | -------------------------------------------------------------------------------- /cmd/list/list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "gitern/account" 7 | "gitern/art" 8 | "gitern/repo" 9 | "io" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | func printWalk(w io.Writer, path string, rel bool, privateAccess bool) { 16 | _, err := os.Stat(path) 17 | if err != nil { 18 | return 19 | } 20 | 21 | err = filepath.Walk(path, func(fpath string, info os.FileInfo, err error) error { 22 | // we ignore the errors as they are likely perm error when we don't have private access 23 | if err != nil { 24 | // we shouldn't have errors if we have private access 25 | if privateAccess { 26 | art.Tower.Fatal(path, "inner walk", err.Error()) 27 | } 28 | 29 | // if we don't have private access, it's none of their business 30 | // we also expect to have perms errors 31 | return nil 32 | } 33 | if info.IsDir() && len(info.Name()) > 4 && 34 | strings.HasSuffix(info.Name(), ".git") { 35 | if rel { 36 | fpath, err = filepath.Rel(path, fpath) 37 | if err != nil { 38 | art.Tower.Fatal(path, "make relative", err.Error()) 39 | } 40 | } 41 | 42 | if repo.PathPublic(fpath) { 43 | fmt.Fprintln(w, fpath+" (public)") 44 | } else if privateAccess { 45 | fmt.Fprintln(w, fpath) 46 | } 47 | } 48 | return nil 49 | }) 50 | if err != nil { 51 | art.Tower.Fatal(path, "outer walk", err.Error()) 52 | } 53 | } 54 | 55 | func main() { 56 | w := bufio.NewWriter(os.Stdout) 57 | defer w.Flush() 58 | if len(os.Args) == 1 { 59 | accounts, err := account.Accounts() 60 | if err != nil { 61 | art.Tower.Fatal("get accounts", err.Error()) 62 | } 63 | for _, acc := range accounts { 64 | path, err := acc.Path() 65 | if err != nil { 66 | art.Tower.Fatal("account path", err.Error()) 67 | } 68 | 69 | err = os.Chdir(path) 70 | if err != nil { 71 | art.Tower.Fatal("account path", err.Error()) 72 | } 73 | 74 | printWalk(w, acc.Name, false, true) 75 | } 76 | } else if len(os.Args) == 2 { 77 | accName := os.Args[1] 78 | printWalk(w, accName, false, account.CheckAccess(accName) == nil) 79 | } else { 80 | art.Fool.Fatal("Usage: gitern-list []") 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /cmd/no-interactive-login/no-interactive-login.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | fmt.Println(" _ _ ") 10 | fmt.Println(" (_) | ") 11 | fmt.Println(" __ _ _| |_ ___ _ __ _ __ ") 12 | fmt.Println(" / _` | | __/ _ \\ '__| '_ \\ ") 13 | fmt.Println("| (_| | | |_| __/ | | | | |") 14 | fmt.Println(" \\__, |_|\\__\\___|_| |_| |_|") 15 | fmt.Println(" __/ | ") 16 | fmt.Println(" |___/ ") 17 | fmt.Println(" ") 18 | fmt.Println("No interactive login allowed") 19 | os.Exit(128) 20 | } 21 | -------------------------------------------------------------------------------- /cmd/pubkey/add/add.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "database/sql" 6 | "fmt" 7 | "gitern/account" 8 | "gitern/art" 9 | "gitern/db" 10 | "gitern/pubkey" 11 | "gitern/stripehelper" 12 | "os" 13 | 14 | "github.com/omeid/pgerror" 15 | ) 16 | 17 | func main() { 18 | if len(os.Args) != 2 { 19 | art.Fool.Fatal("Usage: gitern-pubkey-add ") 20 | } 21 | 22 | // we recheck their authorization for account just in case 23 | // something slips by Intake's auth checks 24 | accName := os.Args[1] 25 | err := account.CheckAccess(accName) 26 | if err != nil { 27 | art.Fool.Fatal(err.Error()) 28 | } 29 | 30 | // TODO: is this a DOS attack vector if we sit waiting 31 | // to read from stdin? 32 | err = db.DoTxn(func(tx *sql.Tx) error { 33 | scanner := bufio.NewScanner(os.Stdin) 34 | for scanner.Scan() { 35 | err := pubkey.AddLineTx(scanner.Text(), accName, tx) 36 | if err != nil { 37 | switch { 38 | case pgerror.UniqueViolation(err) != nil: 39 | art.Fool.Fatal(accName, "pubkey already exists") 40 | case pgerror.InvalidTextRepresentation(err) != nil: 41 | art.Fool.Fatal(accName, "invalid pubkey type", "should begin with ssh-rsa, ssh-ed25519, etc.") 42 | case pgerror.CheckViolation(err) != nil: 43 | art.Fool.Fatal(accName, "invalid pubkey representation") 44 | default: 45 | art.Tower.Fatal("add line", err.Error()) 46 | } 47 | } 48 | } 49 | if err := scanner.Err(); err != nil { 50 | return err 51 | } 52 | 53 | return stripehelper.ReportUsageTx(accName, tx) 54 | }) 55 | if err != nil { 56 | art.Tower.Fatal("pubkey add", err.Error()) 57 | } 58 | 59 | art.Scroll.Print(fmt.Sprintf("pubkeys granted access to %s", accName)) 60 | } 61 | -------------------------------------------------------------------------------- /cmd/pubkey/list/list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "gitern/account" 7 | "gitern/art" 8 | "gitern/pubkey" 9 | "io" 10 | "os" 11 | ) 12 | 13 | func printKeyList(w io.Writer, accName string, full bool) { 14 | fp := os.Getenv("FP") 15 | 16 | err := account.CheckAccess(accName) 17 | if err != nil { 18 | art.Fool.Fatal(err.Error()) 19 | } 20 | 21 | keys, err := pubkey.List(accName) 22 | if err != nil { 23 | art.Tower.Fatal("listing pubkeys", err.Error()) 24 | } 25 | 26 | for _, k := range keys { 27 | var you string 28 | if k.Fp == fp { 29 | you = " (you)" 30 | } 31 | if full { 32 | fmt.Fprintf(w, "%s %s %s\n", k.KeyType, k.Pubkey, k.Comment) 33 | } else { 34 | fmt.Fprintf(w, "SHA256:%s %s%s\n", k.Fp, k.Comment, you) 35 | } 36 | } 37 | } 38 | 39 | func main() { 40 | full := false 41 | for i, v := range os.Args { 42 | if v == "--full" { 43 | full = true 44 | os.Args = append(os.Args[:i], os.Args[i+1:]...) 45 | } 46 | } 47 | 48 | w := bufio.NewWriter(os.Stdout) 49 | defer w.Flush() 50 | if len(os.Args) == 1 { 51 | accNames, err := account.AccountNames() 52 | if err != nil { 53 | art.Tower.Fatal("get account", err.Error()) 54 | } 55 | for i, accName := range accNames { 56 | fmt.Fprintf(w, "%s:\n", accName) 57 | printKeyList(w, accName, full) 58 | if i != len(accNames)-1 { 59 | fmt.Fprintln(w) 60 | } 61 | } 62 | } else if len(os.Args) == 2 { 63 | printKeyList(w, os.Args[1], full) 64 | } else { 65 | art.Fool.Fatal("Usage: gitern-pubkey-list [--full] []") 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /cmd/pubkey/remove/remove.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "gitern/account" 7 | "gitern/art" 8 | "gitern/db" 9 | _ "gitern/logmill" 10 | "gitern/pubkey" 11 | "gitern/stripehelper" 12 | "os" 13 | "strings" 14 | ) 15 | 16 | const FP_PREFIX = "SHA256:" 17 | 18 | func main() { 19 | if len(os.Args) != 3 { 20 | art.Fool.Fatal("Usage: gitern-pubkey-remove ") 21 | } 22 | 23 | accountName := os.Args[2] 24 | err := account.CheckAccess(accountName) 25 | if err != nil { 26 | art.Fool.Fatal(err.Error()) 27 | } 28 | 29 | fp := os.Args[1] 30 | 31 | if !strings.HasPrefix(fp, FP_PREFIX) { 32 | art.Fool.Fatal(fp, "Fingerprint expected to begin with "+FP_PREFIX) 33 | } 34 | 35 | fp = strings.TrimPrefix(fp, FP_PREFIX) 36 | 37 | err = db.DoTxn(func(tx *sql.Tx) error { 38 | err = pubkey.RemoveFingerprintTx(accountName, fp, tx) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | return stripehelper.ReportUsageTx(accountName, tx) 44 | }) 45 | if err != nil { 46 | art.Tower.Fatal(fp, "removing pubkey", err.Error()) 47 | } 48 | 49 | art.Noose.Print(FP_PREFIX+fp, fmt.Sprintf("access revoked from %s", accountName)) 50 | } 51 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "os" 7 | 8 | _ "github.com/lib/pq" 9 | ) 10 | 11 | var Conn *sql.DB 12 | 13 | // These rds can optionally be passed in at the linker stage 14 | // We do this because we want binaries that don't have access 15 | // to eb env to have these values when run, e.g. authorized-keys 16 | // https://stackoverflow.com/questions/28459102/golang-compile-environment-variable-into-binary 17 | // https://golang.org/cmd/link/ 18 | var ( 19 | RDS_DB_NAME string 20 | RDS_USERNAME string 21 | RDS_PASSWORD string 22 | RDS_HOSTNAME string 23 | RDS_PORT string 24 | ) 25 | 26 | type DBTX interface { 27 | Exec(string, ...interface{}) (sql.Result, error) 28 | Prepare(string) (*sql.Stmt, error) 29 | Query(string, ...interface{}) (*sql.Rows, error) 30 | QueryRow(string, ...interface{}) *sql.Row 31 | } 32 | 33 | // If env vars are available override default or linker values 34 | func override() { 35 | str, exists := os.LookupEnv("RDS_DB_NAME") 36 | if exists { 37 | RDS_DB_NAME = str 38 | } 39 | str, exists = os.LookupEnv("RDS_USERNAME") 40 | if exists { 41 | RDS_USERNAME = str 42 | } 43 | str, exists = os.LookupEnv("RDS_PASSWORD") 44 | if exists { 45 | RDS_PASSWORD = str 46 | } 47 | str, exists = os.LookupEnv("RDS_HOSTNAME") 48 | if exists { 49 | RDS_HOSTNAME = str 50 | } 51 | str, exists = os.LookupEnv("RDS_PORT") 52 | if exists { 53 | RDS_PORT = str 54 | } 55 | } 56 | 57 | func dsn() string { 58 | var dsn string 59 | 60 | override() 61 | 62 | if RDS_DB_NAME == "" { 63 | /* we are in local dev mode */ 64 | RDS_DB_NAME = "gitern sslmode=disable" 65 | } 66 | 67 | envs := map[string]string{ 68 | "database": RDS_DB_NAME, 69 | "user": RDS_USERNAME, 70 | "password": RDS_PASSWORD, 71 | "host": RDS_HOSTNAME, 72 | "port": RDS_PORT, 73 | } 74 | for k, v := range envs { 75 | if v != "" { 76 | dsn = dsn + " " + k + "=" + v 77 | } 78 | } 79 | 80 | return dsn 81 | } 82 | 83 | type Txn func(tx *sql.Tx) error 84 | 85 | func DoTxn(fn Txn) error { 86 | tx, err := Conn.Begin() 87 | defer func() { 88 | if r := recover(); r != nil { 89 | tx.Rollback() 90 | } 91 | }() 92 | if err != nil { 93 | return err 94 | } 95 | 96 | err = fn(tx) 97 | if err != nil { 98 | xerr := tx.Rollback() 99 | if xerr != nil { 100 | return xerr 101 | } 102 | return err 103 | } 104 | 105 | err = tx.Commit() 106 | if err != nil { 107 | return err 108 | } 109 | 110 | return nil 111 | } 112 | 113 | func NewNullString(s string) sql.NullString { 114 | if len(s) == 0 { 115 | return sql.NullString{} 116 | } 117 | return sql.NullString{ 118 | String: s, 119 | Valid: true, 120 | } 121 | } 122 | 123 | func init() { 124 | var err error 125 | Conn, err = sql.Open("postgres", dsn()) 126 | if err != nil { 127 | log.Fatalln(err) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gitern 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 7 | github.com/dpapathanasiou/go-recaptcha v0.0.0-20190121160230-be5090b17804 8 | github.com/dustin/go-humanize v1.0.0 9 | github.com/google/uuid v1.1.1 10 | github.com/howeyc/fsnotify v0.9.0 // indirect 11 | github.com/joho/godotenv v1.3.0 12 | github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e 13 | github.com/lib/pq v1.6.0 14 | github.com/libgit2/git2go/v30 v30.0.5 15 | github.com/mattn/go-colorable v0.1.7 // indirect 16 | github.com/omeid/pgerror v0.0.0-20200121005125-8254ae5f5ba0 17 | github.com/pilu/config v0.0.0-20131214182432-3eb99e6c0b9a // indirect 18 | github.com/pilu/fresh v0.0.0-20190826141211-0fa698148017 // indirect 19 | github.com/stripe/stripe-go v70.15.0+incompatible 20 | github.com/stripe/stripe-go/v71 v71.42.0 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 h1:P5U+E4x5OkVEKQDklVPmzs71WM56RTTRqV4OrDC//Y4= 2 | github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5/go.mod h1:976q2ETgjT2snVCf2ZaBnyBbVoPERGjUz+0sofzEfro= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 5 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 6 | github.com/dpapathanasiou/go-recaptcha v0.0.0-20190121160230-be5090b17804 h1:gFnPvL9HX+Nrb4M2AwzFYqcwGStxYZpuDpFAqpViBG4= 7 | github.com/dpapathanasiou/go-recaptcha v0.0.0-20190121160230-be5090b17804/go.mod h1:eovtlS/D2AGk8vy2a9sO4XzOyHMHb8jM+WPsf9pkgFo= 8 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 9 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 10 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 11 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 12 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 13 | github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 14 | github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= 15 | github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 16 | github.com/howeyc/fsnotify v0.9.0 h1:0gtV5JmOKH4A8SsFxG2BczSeXWWPvcMT0euZt5gDAxY= 17 | github.com/howeyc/fsnotify v0.9.0/go.mod h1:41HzSPxBGeFRQKEEwgh49TRw/nKBsYZ2cF1OzPjSJsA= 18 | github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= 19 | github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= 20 | github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= 21 | github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= 22 | github.com/jcmturner/gofork v1.0.0 h1:J7uCkflzTEhUZ64xqKnkDxq3kzc96ajM1Gli5ktUem8= 23 | github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= 24 | github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= 25 | github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= 26 | github.com/jcmturner/gokrb5/v8 v8.2.0 h1:lzPl/30ZLkTveYsYZPKMcgXc8MbnE6RsTd4F9KgiLtk= 27 | github.com/jcmturner/gokrb5/v8 v8.2.0/go.mod h1:T1hnNppQsBtxW0tCHMHTkAt8n/sABdzZgZdoFrZaZNM= 28 | github.com/jcmturner/rpc/v2 v2.0.2 h1:gMB4IwRXYsWw4Bc6o/az2HJgFUA1ffSh90i26ZJ6Xl0= 29 | github.com/jcmturner/rpc/v2 v2.0.2/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= 30 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 31 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 32 | github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e h1:OGunVjqY7y4U4laftpEHv+mvZBlr7UGimJXKEGQtg48= 33 | github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e/go.mod h1:Fy2gCFfZhay8jplf/Csj6cyH/oshQTkLQYZbKkcV+SY= 34 | github.com/lib/pq v1.6.0 h1:I5DPxhYJChW9KYc66se+oKFFQX6VuQrKiprsX6ivRZc= 35 | github.com/lib/pq v1.6.0/go.mod h1:4vXEAYvW1fRQ2/FhZ78H73A60MHw1geSm145z2mdY1g= 36 | github.com/libgit2/git2go v27.10.0+incompatible h1:P7zNuI907L1Nz7ifXcYKDw9Y8xCOLrnTbyH1gqxcIX8= 37 | github.com/libgit2/git2go v27.10.0+incompatible/go.mod h1:4bKN42efkbNYMZlvDfxGDxzl066GhpvIircZDsm8Y+Y= 38 | github.com/libgit2/git2go/v30 v30.0.5 h1:gxKqXOslpvYDZNC62f8GV34TAk0qw4wZ++IdYw8V9I4= 39 | github.com/libgit2/git2go/v30 v30.0.5/go.mod h1:YReiQ7xhMoyAL4ISYFLZt+OGqn6xtLqvTC1xJ9oAH7Y= 40 | github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= 41 | github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 42 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 43 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 44 | github.com/omeid/pgerror v0.0.0-20200121005125-8254ae5f5ba0 h1:t6wllFIbMrfAHuCUhWDLqzf9HeZbMMRgJoljzTjTP+I= 45 | github.com/omeid/pgerror v0.0.0-20200121005125-8254ae5f5ba0/go.mod h1:eRPo+5Fg84EX1kE4MSnBey5y8moSRQGAaclJaJarP/c= 46 | github.com/pilu/config v0.0.0-20131214182432-3eb99e6c0b9a h1:Tg4E4cXPZSZyd3H1tJlYo6ZreXV0ZJvE/lorNqyw1AU= 47 | github.com/pilu/config v0.0.0-20131214182432-3eb99e6c0b9a/go.mod h1:9Or9aIl95Kp43zONcHd5tLZGKXb9iLx0pZjau0uJ5zg= 48 | github.com/pilu/fresh v0.0.0-20190826141211-0fa698148017 h1:XXDLZIIt9NqdeIEva0DM+z1npM0Tsx6h5TYqwNvXfP0= 49 | github.com/pilu/fresh v0.0.0-20190826141211-0fa698148017/go.mod h1:2LLTtftTZSdAPR/iVyennXZDLZOYzyDn+T0qEKJ8eSw= 50 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 51 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 52 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 53 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 54 | github.com/stripe/stripe-go v1.0.3 h1:RHgK2FUKawVNBPJ15pNM4IWkEVVCg5Ju3xK5QZNhTwY= 55 | github.com/stripe/stripe-go v70.15.0+incompatible h1:hNML7M1zx8RgtepEMlxyu/FpVPrP7KZm1gPFQquJQvM= 56 | github.com/stripe/stripe-go v70.15.0+incompatible/go.mod h1:A1dQZmO/QypXmsL0T8axYZkSN/uA/T/A64pfKdBAMiY= 57 | github.com/stripe/stripe-go/v71 v71.39.0 h1:koL28dKJfP5aunjBU7EuCgI/TpMy/sgqrTjK4D3XO5M= 58 | github.com/stripe/stripe-go/v71 v71.39.0/go.mod h1:BXYwMQe+xjYomcy5/qaTGyoyVMTP3wDCHa7DVFvg8+Y= 59 | github.com/stripe/stripe-go/v71 v71.42.0 h1:fV1RocYkA/V9/eGfKjluuNSLQ+edQxbRvKOWLUhF6pg= 60 | github.com/stripe/stripe-go/v71 v71.42.0/go.mod h1:BXYwMQe+xjYomcy5/qaTGyoyVMTP3wDCHa7DVFvg8+Y= 61 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 62 | golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 63 | golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4 h1:QmwruyY+bKbDDL0BaglrbZABEali68eoMFhTZpCjYVA= 64 | golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 65 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 66 | golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA= 67 | golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 68 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= 69 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 70 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 71 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 72 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 73 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= 74 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 75 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 76 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 77 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 78 | gopkg.in/jcmturner/aescts.v1 v1.0.1/go.mod h1:nsR8qBOg+OucoIW+WMhB3GspUQXq9XorLnQb9XtvcOo= 79 | gopkg.in/jcmturner/dnsutils.v1 v1.0.1/go.mod h1:m3v+5svpVOhtFAP/wSz+yzh4Mc0Fg7eRhxkJMWSIz9Q= 80 | gopkg.in/jcmturner/goidentity.v3 v3.0.0/go.mod h1:oG2kH0IvSYNIu80dVAyu/yoefjq1mNfM5bm88whjWx4= 81 | gopkg.in/jcmturner/gokrb5.v7 v7.5.0/go.mod h1:l8VISx+WGYp+Fp7KRbsiUuXTTOnxIc3Tuvyavf11/WM= 82 | gopkg.in/jcmturner/rpc.v1 v1.1.0/go.mod h1:YIdkC4XfD6GXbzje11McwsDuOlZQSb9W4vfLvuNnlv8= 83 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 84 | -------------------------------------------------------------------------------- /hooks/build-hooks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | LDFLAGS="-X gitern/stripehelper.STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} -X gitern/db.RDS_DB_NAME=${RDS_DB_NAME} -X gitern/db.RDS_USERNAME=${RDS_USERNAME} -X gitern/db.RDS_PASSWORD=${RDS_PASSWORD} -X gitern/db.RDS_HOSTNAME=${RDS_HOSTNAME} -X gitern/db.RDS_PORT=${RDS_PORT}" 3 | HOOKS_PATH=/jail/etc/git/hooks 4 | 5 | ## pre-receive 6 | go build -ldflags "${LDFLAGS}" -o $HOOKS_PATH/pre-receive pre-receive/* -------------------------------------------------------------------------------- /hooks/pre-receive/pre-receive.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "gitern/account" 7 | "gitern/art" 8 | _ "gitern/logmill" 9 | "gitern/misc" 10 | "log" 11 | "os" 12 | "os/exec" 13 | 14 | "github.com/dustin/go-humanize" 15 | ) 16 | 17 | // grab quota 18 | // total up the size of the incoming if it causes the user to exceed their 19 | // quota, warn 20 | 21 | const NULLSHA = "0000000000000000000000000000000000000000" 22 | 23 | func main() { 24 | free, err := misc.EnvToLimit("FREE") 25 | if err != nil { 26 | log.Fatalln(err) 27 | } 28 | scanner := bufio.NewScanner(os.Stdin) 29 | var oldref, newref string 30 | var totalSize int64 31 | for scanner.Scan() { 32 | _, err := fmt.Sscan(scanner.Text(), &oldref, &newref) 33 | if err != nil { 34 | log.Fatalln("scanning input", err) 35 | } 36 | 37 | if newref == NULLSHA { 38 | continue 39 | } 40 | 41 | var target string 42 | if oldref == NULLSHA { 43 | target = newref 44 | } else { 45 | target = fmt.Sprintf("%s..%s", oldref, newref) 46 | } 47 | 48 | // get all the incoming objects 49 | revList := exec.Command("git", "rev-list", "--objects", "--use-bitmap-index", target, "--not", "--branches=/*", "--tags=/*") 50 | // cat them out with their sizes 51 | catFile := exec.Command("git", "cat-file", "--buffer", "--batch-check=%(objectsize:disk) %(rest)") 52 | // pipe rev-list into cat-file 53 | catFile.Stdin, err = revList.StdoutPipe() 54 | if err != nil { 55 | log.Fatalln(err) 56 | } 57 | 58 | // capture cat-file's output 59 | catFilePipe, err := catFile.StdoutPipe() 60 | if err != nil { 61 | log.Fatalln(err) 62 | } 63 | 64 | err = catFile.Start() 65 | if err != nil { 66 | log.Fatalln(err) 67 | } 68 | 69 | err = revList.Start() 70 | if err != nil { 71 | log.Fatalln(err) 72 | } 73 | 74 | var size int64 75 | catFileScanner := bufio.NewScanner(catFilePipe) 76 | for catFileScanner.Scan() { 77 | _, err := fmt.Sscan(catFileScanner.Text(), &size) 78 | if err != nil { 79 | log.Fatalln("scanning catfile", err) 80 | } 81 | totalSize += size 82 | } 83 | 84 | if err := catFileScanner.Err(); err != nil { 85 | log.Fatalln(err) 86 | } 87 | 88 | err = catFile.Wait() 89 | if err != nil { 90 | log.Fatalln(err) 91 | } 92 | 93 | err = revList.Wait() 94 | if err != nil { 95 | log.Fatalln(err) 96 | } 97 | } 98 | 99 | if err := scanner.Err(); err != nil { 100 | log.Fatalln(err) 101 | } 102 | 103 | accname := os.Getenv("ACTIVE_ACCOUNT") 104 | fp := os.Getenv("FP") 105 | if totalSize > free { 106 | if free < 0 { 107 | free = 0 108 | } 109 | 110 | id, err := account.CreateSession(accname, fp) 111 | if err != nil { 112 | log.Fatalln(err) 113 | } 114 | 115 | art.Scales.Fatal( 116 | fmt.Sprintf("Commit of %s exceeds your remaining quota of %s on '%s.'", 117 | humanize.Bytes(uint64(totalSize)), humanize.Bytes(uint64(free)), accname), 118 | fmt.Sprintf("Get unlimited storage on gitern; add a payment method to '%s.'", accname), 119 | fmt.Sprintf("Visit gitern.com/%s in a web browser.", id), 120 | ) 121 | } else if float64(free-totalSize) <= float64(totalSize)*0.2 { 122 | art.Scales.Print( 123 | fmt.Sprintf("Heads up! <20%% (%s) of your account quota remains", 124 | humanize.Bytes(uint64(free-totalSize))), 125 | ) 126 | } 127 | return 128 | } 129 | -------------------------------------------------------------------------------- /logmill/logmill.go: -------------------------------------------------------------------------------- 1 | package logmill 2 | 3 | import "log" 4 | 5 | // all this package does right now is initialize logging to not have flags 6 | // in the future we should probably actually record logs 7 | 8 | // to disable log flags (eg timestamp printing) for user facing error messages 9 | // import _ "gitern/logmill" 10 | func init() { 11 | log.SetFlags(0) 12 | } 13 | -------------------------------------------------------------------------------- /misc/misc.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "math" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | ) 9 | 10 | // if limit is not found in env, we set to max 11 | func EnvToLimit(key string) (int64, error) { 12 | val := os.Getenv(key) 13 | var intVal int64 = math.MaxInt64 14 | if val != "" { 15 | var err error 16 | intVal, err = strconv.ParseInt(val, 10, 64) 17 | if err != nil { 18 | return 0, err 19 | } 20 | } 21 | return intVal, nil 22 | } 23 | 24 | func DiskUsage(path string) (int64, error) { 25 | var totalBytes int64 26 | err := filepath.Walk(path, func(fpath string, info os.FileInfo, err error) error { 27 | if err != nil { 28 | return err 29 | } 30 | totalBytes += info.Size() 31 | return nil 32 | }) 33 | if err != nil { 34 | return 0, err 35 | } 36 | return totalBytes, nil 37 | } 38 | -------------------------------------------------------------------------------- /pubkey/pubkey.go: -------------------------------------------------------------------------------- 1 | package pubkey 2 | 3 | import ( 4 | "gitern/db" 5 | "strings" 6 | ) 7 | 8 | func splitLine(line string) (string, string, string) { 9 | parts := strings.Split(line, " ") 10 | switch len(parts) { 11 | case 0: 12 | return "", "", "" 13 | case 1: 14 | return parts[0], "", "" 15 | case 2: 16 | return parts[0], parts[1], "" 17 | default: 18 | return parts[0], parts[1], parts[2] 19 | } 20 | } 21 | 22 | func AddLineTx(line, account string, tx db.DBTX) error { 23 | keytype, key, comment := splitLine(line) 24 | 25 | // if they pubkey already exists, continue ie do nothing 26 | _, err := tx.Exec(`INSERT INTO pubkeys (fingerprint, keytype, pubkey, comment) 27 | VALUES (pubkey2fingerprint($2), $1, $2, $3) 28 | ON CONFLICT ON CONSTRAINT pubkeys_pkey DO NOTHING`, 29 | keytype, key, comment) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | _, err = tx.Exec(`INSERT INTO accounts_pubkeys (account_name, pubkey_fingerprint) 35 | VALUES ($1, pubkey2fingerprint($2))`, account, key) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | return nil 41 | } 42 | 43 | type Key struct { 44 | KeyType string 45 | Pubkey string 46 | Comment string 47 | Fp string 48 | } 49 | 50 | func ListTx(account string, tx db.DBTX) ([]Key, error) { 51 | var keys []Key 52 | 53 | rows, err := tx.Query(`SELECT fingerprint, keytype, pubkey, comment 54 | FROM pubkeys 55 | JOIN accounts_pubkeys 56 | ON fingerprint = accounts_pubkeys.pubkey_fingerprint 57 | WHERE accounts_pubkeys.account_name = $1`, account) 58 | if err != nil { 59 | return nil, err 60 | } 61 | defer rows.Close() 62 | 63 | for rows.Next() { 64 | var key Key 65 | if err := rows.Scan(&key.Fp, &key.KeyType, &key.Pubkey, &key.Comment); err != nil { 66 | return nil, err 67 | } 68 | keys = append(keys, key) 69 | } 70 | 71 | return keys, nil 72 | } 73 | 74 | func List(account string) ([]Key, error) { 75 | return ListTx(account, db.Conn) 76 | } 77 | 78 | func CountTx(account string, tx db.DBTX) (int, error) { 79 | keys, err := ListTx(account, tx) 80 | if err != nil { 81 | return 0, err 82 | } 83 | 84 | return len(keys), err 85 | } 86 | 87 | func Count(account string) (int, error) { 88 | return CountTx(account, db.Conn) 89 | } 90 | 91 | func RemoveFingerprintTx(account, fp string, tx db.DBTX) error { 92 | _, err := tx.Exec(`DELETE FROM accounts_pubkeys 93 | WHERE account_name = $1 94 | AND pubkey_fingerprint = $2`, account, fp) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | return nil 100 | } 101 | 102 | func RemoveFingerprint(account, fp string) error { 103 | return RemoveFingerprintTx(account, fp, db.Conn) 104 | } 105 | -------------------------------------------------------------------------------- /public/account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/account.png -------------------------------------------------------------------------------- /public/account_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/account_back.png -------------------------------------------------------------------------------- /public/create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/create.png -------------------------------------------------------------------------------- /public/create_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/create_back.png -------------------------------------------------------------------------------- /public/create_back2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/create_back2.png -------------------------------------------------------------------------------- /public/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/delete.png -------------------------------------------------------------------------------- /public/delete_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/delete_back.png -------------------------------------------------------------------------------- /public/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/demo.mp4 -------------------------------------------------------------------------------- /public/demo3.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/demo3.mp4 -------------------------------------------------------------------------------- /public/gitern_knot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/gitern_knot.png -------------------------------------------------------------------------------- /public/gitern_knot_blw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/gitern_knot_blw.png -------------------------------------------------------------------------------- /public/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/list.png -------------------------------------------------------------------------------- /public/list_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/list_back.png -------------------------------------------------------------------------------- /public/me.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/me.jpg -------------------------------------------------------------------------------- /public/me2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/me2.jpg -------------------------------------------------------------------------------- /public/pubkey_add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/pubkey_add.png -------------------------------------------------------------------------------- /public/pubkey_add_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/pubkey_add_back.png -------------------------------------------------------------------------------- /public/pubkey_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/pubkey_list.png -------------------------------------------------------------------------------- /public/pubkey_list_back2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/pubkey_list_back2.png -------------------------------------------------------------------------------- /public/pubkey_remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/pubkey_remove.png -------------------------------------------------------------------------------- /public/pubkey_remove_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huumn/gitern/72dd9d833c801dedb3fa7d79c38389c59456f0c9/public/pubkey_remove_back.png -------------------------------------------------------------------------------- /repo/repo.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | const BASEPATH = "/repos" 11 | 12 | // we check if parents are a .git dir 13 | func insideRepo(path string) bool { 14 | dirs := strings.Split(filepath.Dir(path), "/") 15 | for _, d := range dirs { 16 | if strings.HasSuffix(d, ".git") { 17 | return true 18 | } 19 | } 20 | 21 | return false 22 | } 23 | 24 | func ValidPath(path string) bool { 25 | return path == filepath.Clean(path) && 26 | !strings.HasPrefix(path, "..") 27 | } 28 | 29 | func ValidRepoPath(path string) bool { 30 | return ValidPath(path) && path != "/" 31 | } 32 | 33 | func CanonicalizePath(path string) string { 34 | return filepath.Join(BASEPATH, path) 35 | } 36 | 37 | func CanonicalizeRepoPath(path string) string { 38 | if !strings.HasSuffix(path, ".git") { 39 | path += ".git" 40 | } 41 | return path 42 | } 43 | 44 | // TODO: this is where we control whether a repo is public 45 | // give all non-repo parents permissions of 754 46 | // give the repo dir itself perms depending on if it's public 47 | // if public 755, if not 750 48 | func MakePath(path string, public bool) error { 49 | if insideRepo(path) { 50 | return fmt.Errorf("Inside a git repository") 51 | } 52 | 53 | if PathExists(path) { 54 | return fmt.Errorf("Already exists") 55 | } 56 | 57 | err := os.MkdirAll(path, 0755) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | // if the repo is private we chmod the repo 63 | if !public { 64 | return os.Chmod(path, 0750) 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func PathExists(path string) bool { 71 | _, err := os.Stat(path) 72 | return !os.IsNotExist(err) 73 | } 74 | 75 | func PathPublic(path string) bool { 76 | info, err := os.Stat(path) 77 | if err != nil || !info.IsDir() { 78 | return false 79 | } 80 | 81 | // this is a public repo if it has "everyone" perms set 82 | return info.Mode().Perm()&0007 > 0 83 | } 84 | 85 | func CleanParents(path string) { 86 | // remove empty parents of path 87 | parent := filepath.Dir(path) 88 | for parent != BASEPATH { 89 | err := os.Remove(parent) 90 | // if this is not an empty dir we expect an err 91 | // and return. Because this is a nice to have 92 | // rather than a must have we ignore other errors 93 | if err != nil { 94 | break 95 | } 96 | parent = filepath.Dir(parent) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS citext; 2 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 3 | 4 | DO $$ BEGIN 5 | CREATE DOMAIN email AS citext 6 | CHECK ( value ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' ); 7 | EXCEPTION 8 | WHEN duplicate_object THEN null; 9 | END $$; 10 | 11 | DO $$ BEGIN 12 | CREATE DOMAIN accname AS citext 13 | CHECK ( value ~ '^(?=.{1,40}$)(?![-_])(?!.*[-_]{2})[a-zA-Z0-9-]+(? interval '5 minutes'; $$ 105 | LANGUAGE SQL 106 | VOLATILE 107 | RETURNS NULL ON NULL INPUT; 108 | 109 | CREATE OR REPLACE FUNCTION create_session(accname, fingerprint) RETURNS sessionID 110 | AS $$ 111 | DECLARE 112 | newID sessionID; 113 | BEGIN 114 | PERFORM delete_sessions($1, $2); 115 | LOOP 116 | newID := gen_session_id(6); 117 | BEGIN 118 | INSERT INTO sessions (id, name, fingerprint) VALUES (newID, $1, $2); 119 | EXIT; 120 | EXCEPTION WHEN unique_violation THEN 121 | 122 | END; 123 | END LOOP; 124 | RETURN newID; 125 | END; 126 | $$ LANGUAGE PLPGSQL; -------------------------------------------------------------------------------- /scripts/create-jail.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | jail=/jail 3 | accounts=/gitern/accounts 4 | 5 | # create the jail's commissary 6 | mkdir -v -p -m 755 $jail 7 | mkdir -v -p -m 755 $jail/{accounts,dev,git-shell-commands,git-shell-commands-MEDSEC,/usr/share/git-core/templates,/etc/git/hooks} 8 | 9 | ## populate commissary 10 | # binaries 11 | cp -v --parents /usr/bin/git /usr/bin/git-* $jail 12 | # libraries 13 | libs=$(ldd /usr/bin/git* | egrep -o '/lib.*?\.[0-9]*' | sort --unique) 14 | for i in $libs; do 15 | sudo cp -v --parents $i $jail 16 | done 17 | # dev 18 | mknod -m 666 $jail/dev/null c 1 3 19 | mknod -m 666 $jail/dev/zero c 1 5 20 | mknod -m 666 $jail/dev/tty c 5 0 21 | mknod -m 666 $jail/dev/random c 1 8 22 | mknod -m 666 $jail/dev/urandom c 1 9 23 | chown root:tty $jail/dev/tty 24 | 25 | # etc/resolv.conf for resolving rds domain name 26 | # ssl certs to call stripe api 27 | cp -r -v --parents /etc/pki /etc/ssl /etc/resolv.conf $jail 28 | 29 | # git configure --global (ie HOME) to use /etc/git/hooks 30 | git config --file $jail/etc/gitconfig core.hooksPath /etc/git/hooks 31 | 32 | ## mount accounts in cell 33 | if ! findmnt --mtab --target $jail/accounts; then 34 | mount -v -o bind,nosuid $accounts $jail/accounts 35 | fi -------------------------------------------------------------------------------- /sessions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rsa" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "os" 10 | "time" 11 | 12 | "github.com/dgrijalva/jwt-go" 13 | ) 14 | 15 | const ( 16 | secKey = "cookie.key" // openssl genrsa -out cookie.key 17 | pubKey = "cookie.pub" // openssl rsa -in cookie.key -pubout > cookie.pub 18 | sessionLength = 1 * time.Hour 19 | sessionName = "session" 20 | ) 21 | 22 | func decryptToken(encToken string) (string, string, error) { 23 | token, err := jwt.ParseWithClaims(encToken, &AdminClaims{}, func(token *jwt.Token) (interface{}, error) { 24 | return verifyKey, nil 25 | }) 26 | if err != nil { 27 | return "", "", err 28 | } 29 | 30 | claims := token.Claims.(*AdminClaims) 31 | 32 | return claims.Account, claims.Fingerprint, nil 33 | } 34 | 35 | func getSession(r *http.Request) (string, string, error) { 36 | cookie, err := r.Cookie(sessionName) 37 | if err != nil || cookie == nil { 38 | return "", "", fmt.Errorf("No session set available") 39 | } 40 | 41 | return decryptToken(cookie.Value) 42 | } 43 | 44 | func setEncryptedSession(w http.ResponseWriter, encryptedToken string) { 45 | setCookie(w, sessionName, encryptedToken, int(sessionLength.Seconds())) 46 | } 47 | 48 | func createSession(w http.ResponseWriter, account, fingerprint string) error { 49 | return createSessionLength(w, account, fingerprint, sessionLength) 50 | } 51 | 52 | func createSessionLength(w http.ResponseWriter, account, fingerprint string, 53 | length time.Duration) error { 54 | tokenString, err := createToken(account, fingerprint, length) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | setCookie(w, sessionName, tokenString, int(length.Seconds())) 60 | return err 61 | } 62 | 63 | func setCookie(w http.ResponseWriter, name string, value string, maxAge int) { 64 | c := &http.Cookie{Name: name, MaxAge: maxAge, Value: value, HttpOnly: true, Path: "/"} 65 | http.SetCookie(w, c) 66 | } 67 | 68 | // AdminClaims JWT data 69 | type AdminClaims struct { 70 | jwt.StandardClaims 71 | Account string `json:"account"` 72 | Fingerprint string `json:"fingerprint"` 73 | } 74 | 75 | func createToken(account, fingerprint string, dur time.Duration) (string, error) { 76 | t := jwt.New(jwt.GetSigningMethod("RS256")) 77 | 78 | t.Claims = AdminClaims{ 79 | jwt.StandardClaims{ 80 | // see http://tools.ietf.org/html/draft-ietf-oauth-json-web-token-20#section-4.1.4 81 | // 1 month from now 82 | ExpiresAt: time.Now().Add(dur).Unix(), 83 | }, 84 | account, 85 | fingerprint, 86 | } 87 | 88 | return t.SignedString(signKey) 89 | } 90 | 91 | var ( 92 | verifyKey *rsa.PublicKey 93 | signKey *rsa.PrivateKey 94 | ) 95 | 96 | func init() { 97 | secKey, exists := os.LookupEnv("SECKEY_PATH") 98 | if !exists { 99 | /* we are in local dev mode */ 100 | secKey = "cookie.key" // openssl genrsa -out jwt.key 101 | } 102 | signBytes, err := ioutil.ReadFile(secKey) 103 | if err != nil { 104 | log.Fatal(err) 105 | } 106 | signKey, err = jwt.ParseRSAPrivateKeyFromPEM(signBytes) 107 | if err != nil { 108 | log.Fatal(err) 109 | } 110 | pubKey, exists := os.LookupEnv("PUBKEY_PATH") 111 | if !exists { 112 | /* we are in local dev mode */ 113 | pubKey = "cookie.pub" // openssl genrsa -out jwt.key 114 | } 115 | verifyBytes, err := ioutil.ReadFile(pubKey) 116 | if err != nil { 117 | log.Fatal(err) 118 | } 119 | verifyKey, err = jwt.ParseRSAPublicKeyFromPEM(verifyBytes) 120 | if err != nil { 121 | log.Fatal(err) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /stripe.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "encoding/json" 7 | "gitern/db" 8 | "gitern/stripehelper" 9 | "io" 10 | "io/ioutil" 11 | "log" 12 | "net/http" 13 | "os" 14 | 15 | "github.com/stripe/stripe-go/v71" 16 | portalSession "github.com/stripe/stripe-go/v71/billingportal/session" 17 | "github.com/stripe/stripe-go/v71/customer" 18 | "github.com/stripe/stripe-go/v71/invoice" 19 | "github.com/stripe/stripe-go/v71/paymentmethod" 20 | "github.com/stripe/stripe-go/v71/sub" 21 | "github.com/stripe/stripe-go/v71/webhook" 22 | ) 23 | 24 | func stripeHTTP() { 25 | stripe.Key = os.Getenv("STRIPE_SECRET_KEY") 26 | 27 | http.HandleFunc("/billing-portal", stripeBillingPortal) 28 | http.HandleFunc("/create-subscription", stripeCreateSubscription) 29 | http.HandleFunc("/retry-invoice", stripeRetryInvoice) 30 | http.HandleFunc("/stripe-webhook", stripeWebhook) 31 | } 32 | 33 | func stripeCreateCustomer(name string) (string, error) { 34 | params := &stripe.CustomerParams{ 35 | Name: stripe.String(name), 36 | } 37 | 38 | c, err := customer.New(params) 39 | if err != nil { 40 | return "", err 41 | } 42 | 43 | return c.ID, err 44 | } 45 | 46 | func stripeIDAndStatus(accname string) (string, string, error) { 47 | row := db.Conn.QueryRow(`SELECT stripe_id, stripe_status 48 | FROM accounts 49 | WHERE name = $1`, accname) 50 | var sid, sst sql.NullString 51 | if err := row.Scan(&sid, &sst); err != nil { 52 | return "", "", err 53 | } 54 | 55 | var stripeID string 56 | if sid.Valid { 57 | stripeID = sid.String 58 | } else { 59 | var err error 60 | stripeID, err = stripeCreateCustomer(accname) 61 | if err != nil || stripeID == "" { 62 | return "", "", err 63 | } 64 | // store the stripeID 65 | _, err = db.Conn.Exec("UPDATE accounts SET stripe_id = $1 where name = $2", 66 | stripeID, accname) 67 | if err != nil { 68 | return "", "", err 69 | } 70 | } 71 | 72 | stripeStatus := sst.String 73 | return stripeID, stripeStatus, nil 74 | } 75 | 76 | func stripeBillingPortalAuth(stripeID string, w http.ResponseWriter, r *http.Request) { 77 | params := &stripe.BillingPortalSessionParams{ 78 | Customer: stripe.String(stripeID), 79 | ReturnURL: stripe.String("https://gitern.com/account"), 80 | } 81 | s, _ := portalSession.New(params) 82 | http.Redirect(w, r, s.URL, http.StatusSeeOther) 83 | } 84 | 85 | func stripeBillingPortal(w http.ResponseWriter, r *http.Request) { 86 | accname, _, err := getSession(r) 87 | if err != nil { 88 | errHandler(w, http.StatusUnauthorized) 89 | return 90 | } 91 | 92 | stripeID, _, err := stripeIDAndStatus(accname) 93 | if err != nil { 94 | errHandlerMsg(w, http.StatusInternalServerError, "retrieving stripe status") 95 | return 96 | } 97 | 98 | stripeBillingPortalAuth(stripeID, w, r) 99 | } 100 | 101 | func stripeCreateSubscription(w http.ResponseWriter, r *http.Request) { 102 | if r.Method != "POST" { 103 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 104 | return 105 | } 106 | 107 | var req struct { 108 | PaymentMethodID string `json:"paymentMethodId"` 109 | CustomerID string `json:"customerId"` 110 | PriceID string `json:"priceId"` 111 | } 112 | 113 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 114 | http.Error(w, err.Error(), http.StatusInternalServerError) 115 | log.Printf("json.NewDecoder.Decode: %v", err) 116 | return 117 | } 118 | 119 | // Attach PaymentMethod 120 | params := &stripe.PaymentMethodAttachParams{ 121 | Customer: stripe.String(req.CustomerID), 122 | } 123 | pm, err := paymentmethod.Attach( 124 | req.PaymentMethodID, 125 | params, 126 | ) 127 | if err != nil { 128 | writeJSON(w, struct { 129 | Error error `json:"error"` 130 | }{err}) 131 | return 132 | } 133 | 134 | // Update invoice settings default 135 | customerParams := &stripe.CustomerParams{ 136 | InvoiceSettings: &stripe.CustomerInvoiceSettingsParams{ 137 | DefaultPaymentMethod: stripe.String(pm.ID), 138 | }, 139 | } 140 | c, err := customer.Update( 141 | req.CustomerID, 142 | customerParams, 143 | ) 144 | 145 | if err != nil { 146 | http.Error(w, err.Error(), http.StatusInternalServerError) 147 | log.Printf("customer.Update: %v %s", err, c.ID) 148 | return 149 | } 150 | 151 | // Create subscription 152 | subscriptionParams := &stripe.SubscriptionParams{ 153 | Customer: stripe.String(req.CustomerID), 154 | Items: []*stripe.SubscriptionItemsParams{ 155 | { 156 | Plan: stripe.String(req.PriceID), 157 | }, 158 | }, 159 | } 160 | subscriptionParams.AddExpand("latest_invoice.payment_intent") 161 | subscriptionParams.AddExpand("pending_setup_intent") 162 | 163 | s, err := sub.New(subscriptionParams) 164 | if err != nil { 165 | http.Error(w, err.Error(), http.StatusInternalServerError) 166 | log.Printf("sub.New: %v", err) 167 | return 168 | } 169 | 170 | writeJSON(w, s) 171 | } 172 | 173 | func stripeRetryInvoice(w http.ResponseWriter, r *http.Request) { 174 | if r.Method != "POST" { 175 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 176 | return 177 | } 178 | 179 | var req struct { 180 | CustomerID string `json:"customerId"` 181 | PaymentMethodID string `json:"paymentMethodId"` 182 | InvoiceID string `json:"invoiceId"` 183 | } 184 | 185 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 186 | http.Error(w, err.Error(), http.StatusInternalServerError) 187 | log.Printf("json.NewDecoder.Decode: %v", err) 188 | return 189 | } 190 | 191 | // Attach PaymentMethod 192 | params := &stripe.PaymentMethodAttachParams{ 193 | Customer: stripe.String(req.CustomerID), 194 | } 195 | pm, err := paymentmethod.Attach( 196 | req.PaymentMethodID, 197 | params, 198 | ) 199 | if err != nil { 200 | http.Error(w, err.Error(), http.StatusInternalServerError) 201 | log.Printf("paymentmethod.Attach: %v %s", err, pm.ID) 202 | return 203 | } 204 | 205 | // Update invoice settings default 206 | customerParams := &stripe.CustomerParams{ 207 | InvoiceSettings: &stripe.CustomerInvoiceSettingsParams{ 208 | DefaultPaymentMethod: stripe.String(pm.ID), 209 | }, 210 | } 211 | c, err := customer.Update( 212 | req.CustomerID, 213 | customerParams, 214 | ) 215 | 216 | if err != nil { 217 | http.Error(w, err.Error(), http.StatusInternalServerError) 218 | log.Printf("customer.Update: %v %s", err, c.ID) 219 | return 220 | } 221 | 222 | // Retrieve Invoice 223 | invoiceParams := &stripe.InvoiceParams{} 224 | invoiceParams.AddExpand("payment_intent") 225 | in, err := invoice.Get( 226 | req.InvoiceID, 227 | invoiceParams, 228 | ) 229 | 230 | if err != nil { 231 | http.Error(w, err.Error(), http.StatusInternalServerError) 232 | log.Printf("invoice.Get: %v", err) 233 | return 234 | } 235 | 236 | writeJSON(w, in) 237 | } 238 | 239 | func stripeWebhook(w http.ResponseWriter, r *http.Request) { 240 | if r.Method != "POST" { 241 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 242 | return 243 | } 244 | b, err := ioutil.ReadAll(r.Body) 245 | if err != nil { 246 | http.Error(w, err.Error(), http.StatusBadRequest) 247 | log.Printf("ioutil.ReadAll: %v", err) 248 | return 249 | } 250 | 251 | event, err := webhook.ConstructEvent(b, r.Header.Get("Stripe-Signature"), os.Getenv("STRIPE_WEBHOOK_SECRET")) 252 | if err != nil { 253 | http.Error(w, err.Error(), http.StatusBadRequest) 254 | log.Printf("webhook.ConstructEvent: %v", err) 255 | return 256 | } 257 | 258 | var stripeStatus string 259 | switch event.Type { 260 | case "invoice.paid": 261 | stripeStatus = "paid" 262 | accname, ok := event.Data.Object["customer_name"].(string) 263 | if !ok { 264 | http.Error(w, "event.Data.Object[\"customer_name\"] not string", http.StatusInternalServerError) 265 | log.Println("event.Data.Object[\"customer_name\"] not string", event) 266 | return 267 | } 268 | 269 | err = stripehelper.ReportUsage(accname) 270 | if err != nil { 271 | http.Error(w, err.Error(), http.StatusInternalServerError) 272 | log.Printf("stripeReportUsage: %v", err) 273 | return 274 | } 275 | case "invoice.payment_failed": 276 | stripeStatus = "failed" 277 | case "customer.subscription.deleted": 278 | stripeStatus = "" 279 | default: 280 | log.Printf("unsupported event: %+v\n", event) 281 | return 282 | } 283 | 284 | stripeID, ok := event.Data.Object["customer"].(string) 285 | if !ok { 286 | http.Error(w, "event.Data.Object[\"customer\"] not string", http.StatusInternalServerError) 287 | log.Println("event.Data.Object[\"customer\"] not string", event) 288 | return 289 | } 290 | 291 | _, err = db.Conn.Exec("UPDATE accounts SET stripe_status = $1 where stripe_id = $2", 292 | db.NewNullString(stripeStatus), stripeID) 293 | if err != nil { 294 | http.Error(w, err.Error(), http.StatusInternalServerError) 295 | return 296 | } 297 | } 298 | 299 | func writeJSON(w http.ResponseWriter, v interface{}) { 300 | var buf bytes.Buffer 301 | if err := json.NewEncoder(&buf).Encode(v); err != nil { 302 | http.Error(w, err.Error(), http.StatusInternalServerError) 303 | log.Printf("json.NewEncoder.Encode: %v", err) 304 | return 305 | } 306 | w.Header().Set("Content-Type", "application/json") 307 | if _, err := io.Copy(w, &buf); err != nil { 308 | log.Printf("io.Copy: %v", err) 309 | return 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /stripehelper/stripehelper.go: -------------------------------------------------------------------------------- 1 | package stripehelper 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "gitern/db" 7 | "gitern/pubkey" 8 | "log" 9 | "os" 10 | "time" 11 | 12 | "github.com/joho/godotenv" 13 | "github.com/stripe/stripe-go/v71" 14 | "github.com/stripe/stripe-go/v71/customer" 15 | "github.com/stripe/stripe-go/v71/usagerecord" 16 | ) 17 | 18 | var ( 19 | STRIPE_SECRET_KEY string 20 | ) 21 | 22 | func ReportUsageTx(accountName string, tx db.DBTX) error { 23 | row := tx.QueryRow(`SELECT stripe_id, stripe_status 24 | FROM accounts 25 | WHERE name = $1`, accountName) 26 | var sid, sst sql.NullString 27 | if err := row.Scan(&sid, &sst); err != nil { 28 | return err 29 | } 30 | 31 | // don't report usage if we aren't a paying customer 32 | stripeID := sid.String 33 | stripeStatus := sst.String 34 | if stripeStatus != "paid" { 35 | return nil 36 | } 37 | 38 | count, err := pubkey.CountTx(accountName, tx) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | cust, err := customer.Get(stripeID, nil) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if len(cust.Subscriptions.Data) == 0 || len(cust.Subscriptions.Data[0].Items.Data) == 0 { 49 | return fmt.Errorf("Stripe customer object lacks expected subscription data") 50 | } 51 | 52 | _, err = usagerecord.New(&stripe.UsageRecordParams{ 53 | SubscriptionItem: stripe.String(cust.Subscriptions.Data[0].Items.Data[0].ID), 54 | Timestamp: stripe.Int64(time.Now().Unix()), 55 | Quantity: stripe.Int64(int64(count)), 56 | Action: stripe.String(stripe.UsageRecordActionSet), 57 | }) 58 | 59 | return err 60 | } 61 | 62 | func ReportUsage(accountName string) error { 63 | return ReportUsageTx(accountName, db.Conn) 64 | } 65 | 66 | func init() { 67 | godotenv.Load() 68 | 69 | str, exists := os.LookupEnv("STRIPE_SECRET_KEY") 70 | if exists { 71 | STRIPE_SECRET_KEY = str 72 | } 73 | 74 | if STRIPE_SECRET_KEY == "" { 75 | log.Fatalln("STRIPE_SECRET_KEY is not set") 76 | } 77 | 78 | stripe.Key = STRIPE_SECRET_KEY 79 | } 80 | -------------------------------------------------------------------------------- /views/account.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | gitern account 6 | 7 | 8 | 9 | 10 | 11 | 21 | 22 | 203 | 204 | 205 | 206 |
207 |

Muh lord, thou administer the gitern account directory {{.accname}} with pubkey SHA256:{{.fp}}

208 | 209 |
210 |
211 |
212 |

How to use gitern

213 |
    214 |
  1. Download a Gitern CLI 215 | 248 |
  2. 249 |
  3. Run 'gitern help' for usage
  4. 250 |
  5. Verify the gitern.com host key fingerprint when connecting: 251 |
      252 |
    • SHA256:yLo6gsPFiwqJt9fFlaDY46XH9jdu79RYqCmNFza4ohc (RSA)
    • 253 |
    • SHA256:T/iWDAVmMhzVltXgTQHDJsJU466bSLkyeOVc16i642E (DSA)
    • 254 |
    • SHA256:/MccDAYBTGIvIJizQDQfHqA3jVjug4WWxoZuNNi8OK8 (ECDSA)
    • 255 |
    • SHA256:MEscLQrRa8r+GTNu3rQYgTCqaMEDiZPsgq0AQw0+wkQ (ED25519)
    • 256 |
    257 |
  6. 258 |
259 |
260 |
261 |

view this page

262 |
263 |
264 |
265 | gitern account 266 |
267 |
268 | gitern account ssh api 269 |
270 |
271 |
272 |
273 |
274 |
275 | 288 |
289 |

Most noble user, get unlimited storage on gitern for $5/pubkey/month.

290 |

{{.accname}} currently has {{.pubkeys}} pubkey{{if (gt .pubkeys 1)}}s{{end}}

291 |
292 |
293 |
294 | 295 |
296 | 297 | 298 | 299 | 305 |
306 |
307 |
308 |
309 |