├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── coverage.yml │ ├── dependabot-sync.yml │ ├── goreleaser.yml │ ├── lint.yml │ └── nightly.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── client ├── auth.go ├── client.go ├── client_test.go ├── crypt.go ├── http.go ├── keys.go ├── keys_test.go ├── link.go └── news.go ├── cmd ├── backup_keys.go ├── backup_keys_test.go ├── bio.go ├── cmd.go ├── completion.go ├── crypt.go ├── fs.go ├── id.go ├── import_keys.go ├── import_keys_test.go ├── jwt.go ├── keys.go ├── keysync.go ├── kv.go ├── link.go ├── migrate_account.go ├── name.go ├── post_news.go ├── serve.go ├── serve_migrate.go └── where.go ├── crypt ├── README.md └── crypt.go ├── docker.md ├── docs ├── backup-account.md ├── restore-account.md └── self-hosting.md ├── fs ├── README.md └── fs.go ├── go.mod ├── go.sum ├── kv ├── README.md ├── client.go └── kv.go ├── main.go ├── proto ├── api.go ├── auth.go ├── crypt.go ├── errors.go ├── fs.go ├── kv.go ├── link.go ├── news.go └── user.go ├── server ├── auth.go ├── auth_test.go ├── db │ ├── db.go │ └── sqlite │ │ ├── migration │ │ ├── 0001_foreign_keys.go │ │ └── migration.go │ │ ├── sql.go │ │ └── storage.go ├── http.go ├── http_test.go ├── jwk.go ├── link.go ├── middleware.go ├── server.go ├── ssh.go ├── stats │ ├── noop │ │ └── noop.go │ ├── prometheus │ │ └── prometheus.go │ └── stats.go └── storage │ ├── local │ ├── storage.go │ └── storage_test.go │ └── storage.go ├── systemd.md ├── testserver └── testserver.go └── ui ├── charmclient └── client.go ├── common ├── common.go ├── styles.go └── views.go ├── info └── info.go ├── keys ├── keys.go └── keyview.go ├── link ├── link.go └── linkhandler.go ├── linkgen ├── linkgen.go └── linkhandler.go ├── ui.go └── username └── username.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Setup** 14 | Please complete the following information along with version numbers, if applicable. 15 | - OS [e.g. Ubuntu, macOS] 16 | - Shell [e.g. zsh, fish] 17 | - Terminal Emulator [e.g. kitty, iterm] 18 | - Terminal Multiplexer [e.g. tmux] 19 | 20 | **To Reproduce** 21 | Steps to reproduce the behavior: 22 | 1. Go to '...' 23 | 2. Click on '....' 24 | 3. Scroll down to '....' 25 | 4. See error 26 | 27 | **Source Code** 28 | Please include source code if needed to reproduce the behavior. 29 | 30 | **Expected behavior** 31 | A clear and concise description of what you expected to happen. 32 | 33 | **Screenshots** 34 | Add screenshots to help explain your problem. 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Discord 4 | url: https://charm.sh/discord 5 | about: Chat on our Discord. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "05:00" 10 | timezone: "America/New_York" 11 | labels: 12 | - "dependencies" 13 | commit-message: 14 | prefix: "chore" 15 | include: "scope" 16 | 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | day: "monday" 22 | time: "05:00" 23 | timezone: "America/New_York" 24 | labels: 25 | - "dependencies" 26 | commit-message: 27 | prefix: "chore" 28 | include: "scope" 29 | 30 | - package-ecosystem: "docker" 31 | directory: "/" 32 | schedule: 33 | interval: "weekly" 34 | day: "monday" 35 | time: "05:00" 36 | timezone: "America/New_York" 37 | labels: 38 | - "dependencies" 39 | commit-message: 40 | prefix: "feat" 41 | include: "scope" 42 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | uses: charmbracelet/meta/.github/workflows/build.yml@main 8 | 9 | snapshot: 10 | uses: charmbracelet/meta/.github/workflows/snapshot.yml@main 11 | secrets: 12 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | coverage: 6 | strategy: 7 | matrix: 8 | go-version: [~1.20, ^1] 9 | os: [ubuntu-latest] 10 | runs-on: ${{ matrix.os }} 11 | env: 12 | GO111MODULE: "on" 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Coverage 23 | env: 24 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | run: | 26 | go test -race -covermode atomic -coverprofile=profile.cov ./... 27 | go install github.com/mattn/goveralls@latest 28 | goveralls -coverprofile=profile.cov -service=github 29 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-sync.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-sync 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 0" # every Sunday at midnight 5 | workflow_dispatch: # allows manual triggering 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot-sync: 13 | uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main 14 | with: 15 | repo_name: ${{ github.event.repository.name }} 16 | secrets: 17 | gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - v*.*.* 7 | 8 | concurrency: 9 | group: goreleaser 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | goreleaser: 14 | uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main 15 | secrets: 16 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 17 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 18 | gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 19 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 20 | fury_token: ${{ secrets.FURY_TOKEN }} 21 | nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} 22 | nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | golangci: 8 | name: lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: golangci-lint 13 | uses: golangci/golangci-lint-action@v4 14 | with: 15 | # Optional: golangci-lint command line arguments. 16 | args: --issues-exit-code=0 17 | # Optional: show only new issues if it's a pull request. The default value is `false`. 18 | only-new-issues: true 19 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: nightly 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | nightly: 10 | uses: charmbracelet/meta/.github/workflows/nightly.yml@main 11 | secrets: 12 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 13 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 14 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | charm 2 | charm.exe 3 | data 4 | *.db 5 | *.log 6 | dist 7 | cmd/coverage.out 8 | charm-keys-backup.tar 9 | completions/ 10 | manpages/ -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | 4 | issues: 5 | include: 6 | - EXC0001 7 | - EXC0005 8 | - EXC0011 9 | - EXC0012 10 | - EXC0013 11 | 12 | max-issues-per-linter: 0 13 | max-same-issues: 0 14 | 15 | linters: 16 | enable: 17 | - bodyclose 18 | - dupl 19 | - exportloopref 20 | - goconst 21 | - godot 22 | - godox 23 | - goimports 24 | - goprintffuncname 25 | - gosec 26 | - misspell 27 | - prealloc 28 | - revive 29 | - rowserrcheck 30 | - sqlclosecheck 31 | - unconvert 32 | - unparam 33 | - whitespace 34 | 35 | rules: 36 | - linters: 37 | - godox 38 | severity: warn 39 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | includes: 2 | - from_url: 3 | url: charmbracelet/meta/main/goreleaser-semi.yaml 4 | 5 | variables: 6 | binary_name: charm 7 | description: "The Charm Tool and Library 🌟" 8 | github_url: "https://github.com/charmbracelet/charm" 9 | maintainer: "Christian Rocha " 10 | brew_commit_author_name: "Christian Rocha" 11 | brew_commit_author_email: "christian@charm.sh" 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY charm /usr/local/bin/charm 3 | 4 | # Create /data directory 5 | WORKDIR /data 6 | # Expose data volume 7 | VOLUME /data 8 | ENV CHARM_SERVER_DATA_DIR "/data" 9 | 10 | # Expose ports 11 | # SSH 12 | EXPOSE 35353/tcp 13 | # HTTP 14 | EXPOSE 35354/tcp 15 | # Stats 16 | EXPOSE 35355/tcp 17 | # Health 18 | EXPOSE 35356/tcp 19 | 20 | # Set the default command 21 | ENTRYPOINT [ "/usr/local/bin/charm" ] 22 | CMD [ "serve" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2023 Charmbracelet, Inc 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 | -------------------------------------------------------------------------------- /client/auth.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | charm "github.com/charmbracelet/charm/proto" 7 | jwt "github.com/golang-jwt/jwt/v4" 8 | ) 9 | 10 | // Auth will authenticate a client and cache the result. It will return a 11 | // proto.Auth with the JWT and encryption keys for a user. 12 | func (cc *Client) Auth() (*charm.Auth, error) { 13 | cc.authLock.Lock() 14 | defer cc.authLock.Unlock() 15 | 16 | if cc.claims == nil || cc.claims.Valid() != nil { 17 | auth := &charm.Auth{} 18 | s, err := cc.sshSession() 19 | if err != nil { 20 | return nil, charm.ErrAuthFailed{Err: err} 21 | } 22 | defer s.Close() // nolint:errcheck 23 | 24 | b, err := s.Output("api-auth") 25 | if err != nil { 26 | return nil, charm.ErrAuthFailed{Err: err} 27 | } 28 | err = json.Unmarshal(b, auth) 29 | if err != nil { 30 | return nil, charm.ErrAuthFailed{Err: err} 31 | } 32 | cc.httpScheme = auth.HTTPScheme 33 | p := &jwt.Parser{} 34 | token, _, err := p.ParseUnverified(auth.JWT, &jwt.RegisteredClaims{}) 35 | if err != nil { 36 | return nil, charm.ErrAuthFailed{Err: err} 37 | } 38 | cc.claims = token.Claims.(*jwt.RegisteredClaims) 39 | cc.auth = auth 40 | if err != nil { 41 | return nil, charm.ErrAuthFailed{Err: err} 42 | } 43 | } 44 | return cc.auth, nil 45 | } 46 | 47 | // InvalidateAuth clears the JWT auth cache, forcing subsequent Auth() to fetch 48 | // a new JWT from the server. 49 | func (cc *Client) InvalidateAuth() { 50 | cc.authLock.Lock() 51 | defer cc.authLock.Unlock() 52 | cc.claims = nil 53 | cc.auth = nil 54 | } 55 | -------------------------------------------------------------------------------- /client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | os.Exit(m.Run()) 11 | } 12 | 13 | func TestNameValidation(t *testing.T) { 14 | if ValidateName("") { 15 | t.Error("validated the empty string, which should have failed") 16 | } 17 | if !ValidateName("a") { 18 | t.Error("failed validating the single character 'a', which should have passed") 19 | } 20 | if !ValidateName("A") { 21 | t.Error("failed validating the single character 'A', which should have passed") 22 | } 23 | if ValidateName("épicerie") { 24 | t.Error("validated a string with an 'é', which should have failed") 25 | } 26 | if ValidateName("straße") { 27 | t.Error("validated a string with an 'ß', which should have failed") 28 | } 29 | if ValidateName("mr.green") { 30 | t.Error("validated a string with a period, which should have failed") 31 | } 32 | if ValidateName("mister green") { 33 | t.Error("validated a string with a space, which should have failed") 34 | } 35 | if ValidateName("茶") { 36 | t.Error("validated the string '茶', which should have failed") 37 | } 38 | if ValidateName("😀") { 39 | t.Error("validated an emoji, which should have failed") 40 | } 41 | if !ValidateName(strings.Repeat("x", 50)) { 42 | t.Error("falied validating a 50-character-string, which should have passed") 43 | } 44 | if ValidateName(strings.Repeat("x", 51)) { 45 | t.Error("validated a 51-character-string, which should have failed") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/crypt.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "fmt" 8 | "io" 9 | "strings" 10 | "time" 11 | 12 | charm "github.com/charmbracelet/charm/proto" 13 | "github.com/google/uuid" 14 | "github.com/muesli/sasquatch" 15 | ) 16 | 17 | // KeyForID returns the decrypted EncryptKey for a given key ID. 18 | func (cc *Client) KeyForID(gid string) (*charm.EncryptKey, error) { 19 | if len(cc.plainTextEncryptKeys) == 0 { 20 | err := cc.cryptCheck() 21 | if err != nil { 22 | return nil, fmt.Errorf("failed crypt check: %w", err) 23 | } 24 | } 25 | if gid == "" { 26 | if len(cc.plainTextEncryptKeys) == 0 { 27 | return nil, fmt.Errorf("no keys stored") 28 | } 29 | return cc.plainTextEncryptKeys[0], nil 30 | } 31 | for _, k := range cc.plainTextEncryptKeys { 32 | if k.ID == gid { 33 | return k, nil 34 | } 35 | } 36 | return nil, fmt.Errorf("key not found for id %s", gid) 37 | } 38 | 39 | // DefaultEncryptKey returns the default EncryptKey for an authed user. 40 | func (cc *Client) DefaultEncryptKey() (*charm.EncryptKey, error) { 41 | return cc.KeyForID("") 42 | } 43 | 44 | func (cc *Client) findIdentities() ([]sasquatch.Identity, error) { 45 | keys, err := cc.findAuthKeys(cc.Config.KeyType) 46 | if err != nil { 47 | return nil, err 48 | } 49 | var ids []sasquatch.Identity 50 | for _, v := range keys { 51 | id, err := sasquatch.ParseIdentitiesFile(v) 52 | if err == nil { 53 | ids = append(ids, id...) 54 | } 55 | } 56 | return ids, nil 57 | } 58 | 59 | // EncryptKeys returns all of the symmetric encrypt keys for the authed user. 60 | func (cc *Client) EncryptKeys() ([]*charm.EncryptKey, error) { 61 | if err := cc.cryptCheck(); err != nil { 62 | return nil, err 63 | } 64 | return cc.plainTextEncryptKeys, nil 65 | } 66 | 67 | func (cc *Client) addEncryptKey(pk string, gid string, key string, createdAt *time.Time) error { 68 | buf := bytes.NewBuffer(nil) 69 | r, err := sasquatch.ParseRecipient(pk) 70 | if err != nil { 71 | return err 72 | } 73 | w, err := sasquatch.Encrypt(buf, r) 74 | if err != nil { 75 | return err 76 | } 77 | if _, err := w.Write([]byte(key)); err != nil { 78 | return err 79 | } 80 | if err := w.Close(); err != nil { 81 | return err 82 | } 83 | 84 | encKey := base64.StdEncoding.EncodeToString(buf.Bytes()) 85 | ek := charm.EncryptKey{} 86 | ek.PublicKey = pk 87 | ek.ID = gid 88 | ek.Key = encKey 89 | ek.CreatedAt = createdAt 90 | 91 | return cc.AuthedJSONRequest("POST", "/v1/encrypt-key", &ek, nil) 92 | } 93 | 94 | func (cc *Client) cryptCheck() error { 95 | cc.encryptKeyLock.Lock() 96 | defer cc.encryptKeyLock.Unlock() 97 | auth, err := cc.Auth() 98 | if err != nil { 99 | return err 100 | } 101 | 102 | if len(auth.EncryptKeys) == 0 && len(cc.plainTextEncryptKeys) == 0 { 103 | // if there are no encrypt keys, make one for the public key returned from auth 104 | b := make([]byte, 64) 105 | _, err := rand.Read(b) 106 | if err != nil { 107 | return err 108 | } 109 | k := base64.StdEncoding.EncodeToString(b) 110 | ek := &charm.EncryptKey{} 111 | ek.PublicKey = auth.PublicKey 112 | ek.ID = uuid.New().String() 113 | ek.Key = k 114 | err = cc.addEncryptKey(ek.PublicKey, ek.ID, ek.Key, nil) 115 | if err != nil { 116 | return err 117 | } 118 | cc.plainTextEncryptKeys = []*charm.EncryptKey{ek} 119 | return nil 120 | } 121 | 122 | if len(auth.EncryptKeys) > len(cc.plainTextEncryptKeys) { 123 | // if the encryptKeys haven't been decrypted yet, use the sasquatch ids to decrypt them 124 | sids, err := cc.findIdentities() 125 | if err != nil { 126 | return err 127 | } 128 | ks := make([]*charm.EncryptKey, 0) 129 | for _, k := range auth.EncryptKeys { 130 | ds, err := base64.StdEncoding.DecodeString(k.Key) 131 | if err != nil { 132 | return err 133 | } 134 | dr, err := sasquatch.Decrypt(bytes.NewReader(ds), sids...) 135 | if err != nil { 136 | return err 137 | } 138 | 139 | buf := new(strings.Builder) 140 | _, err = io.Copy(buf, dr) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | dk := &charm.EncryptKey{} 146 | dk.Key = buf.String() 147 | dk.PublicKey = k.PublicKey 148 | dk.ID = k.ID 149 | dk.CreatedAt = k.CreatedAt 150 | ks = append(ks, dk) 151 | } 152 | cc.plainTextEncryptKeys = ks 153 | } 154 | 155 | return nil 156 | } 157 | -------------------------------------------------------------------------------- /client/http.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | 12 | charm "github.com/charmbracelet/charm/proto" 13 | ) 14 | 15 | // ErrRequestTooLarge is an error for a request that is too large. 16 | type ErrRequestTooLarge struct { 17 | Size int64 18 | Limit int64 19 | } 20 | 21 | func (err ErrRequestTooLarge) Error() string { 22 | return fmt.Sprintf("request too large: %d > %d", err.Size, err.Limit) 23 | } 24 | 25 | // AuthedRequest sends an authorized JSON request to the Charm and Glow HTTP servers. 26 | func (cc *Client) AuthedJSONRequest(method string, path string, reqBody interface{}, respBody interface{}) error { 27 | buf := &bytes.Buffer{} 28 | err := json.NewEncoder(buf).Encode(reqBody) 29 | if err != nil { 30 | return err 31 | } 32 | headers := http.Header{ 33 | "Content-Type": []string{"application/json"}, 34 | } 35 | resp, err := cc.AuthedRequest(method, path, headers, buf) 36 | if err != nil { 37 | return err 38 | } 39 | if respBody != nil { 40 | defer resp.Body.Close() // nolint:errcheck 41 | dec := json.NewDecoder(resp.Body) 42 | return dec.Decode(respBody) 43 | } 44 | return nil 45 | } 46 | 47 | // AuthedRequest sends an authorized request to the Charm and Glow HTTP servers. 48 | func (cc *Client) AuthedRequest(method string, path string, headers http.Header, reqBody io.Reader) (*http.Response, error) { 49 | client := &http.Client{} 50 | cfg := cc.Config 51 | auth, err := cc.Auth() 52 | if err != nil { 53 | return nil, err 54 | } 55 | jwt := auth.JWT 56 | req, err := http.NewRequest(method, fmt.Sprintf("%s://%s:%d%s", cc.httpScheme, cfg.Host, cfg.HTTPPort, path), reqBody) 57 | if err != nil { 58 | return nil, err 59 | } 60 | for k, v := range headers { 61 | for _, vv := range v { 62 | req.Header.Add(k, vv) 63 | if k == "Content-Length" { 64 | req.ContentLength, _ = strconv.ParseInt(vv, 10, 64) 65 | } 66 | } 67 | } 68 | req.Header.Add("Authorization", fmt.Sprintf("bearer %s", jwt)) 69 | resp, err := client.Do(req) 70 | if err != nil { 71 | return nil, err 72 | } 73 | if statusCode := resp.StatusCode; statusCode >= 300 { 74 | err = fmt.Errorf("server error: %d %s", statusCode, http.StatusText(statusCode)) 75 | // try to decode the error message 76 | if strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { 77 | msg := charm.Message{} 78 | _ = json.NewDecoder(resp.Body).Decode(&msg) 79 | if msg.Message != "" { 80 | err = fmt.Errorf("%s: %s", err, msg.Message) 81 | } 82 | } 83 | return resp, err 84 | } 85 | return resp, nil 86 | } 87 | 88 | // AuthedRawRequest sends an authorized request with no request body to the Charm and Glow HTTP servers. 89 | func (cc *Client) AuthedRawRequest(method string, path string) (*http.Response, error) { 90 | return cc.AuthedRequest(method, path, nil, nil) 91 | } 92 | -------------------------------------------------------------------------------- /client/keys.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/calmh/randomart" 10 | charm "github.com/charmbracelet/charm/proto" 11 | "github.com/charmbracelet/charm/ui/common" 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | var styles = common.DefaultStyles() 16 | 17 | // Fingerprint is the fingerprint of an SSH key. 18 | type Fingerprint struct { 19 | Algorithm string 20 | Type string 21 | Value string 22 | } 23 | 24 | // String outputs a string representation of the fingerprint. 25 | func (f Fingerprint) String() string { 26 | return fmt.Sprintf( 27 | "%s %s", 28 | styles.ListDim.Render(strings.ToUpper(f.Algorithm)), 29 | styles.ListKey.Render(f.Type+":"+f.Value), 30 | ) 31 | } 32 | 33 | // FingerprintSHA256 returns the algorithm and SHA256 fingerprint for the given 34 | // key. 35 | func FingerprintSHA256(k charm.PublicKey) (Fingerprint, error) { 36 | key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.Key)) 37 | if err != nil { 38 | return Fingerprint{}, fmt.Errorf("failed to parse public key: %w", err) 39 | } 40 | 41 | return Fingerprint{ 42 | Algorithm: algo(key.Type()), 43 | Type: "SHA256", 44 | Value: strings.TrimPrefix(ssh.FingerprintSHA256(key), "SHA256:"), 45 | }, nil 46 | } 47 | 48 | // RandomArt returns the randomart for the given key. 49 | func RandomArt(k charm.PublicKey) (string, error) { 50 | key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.Key)) 51 | if err != nil { 52 | return "", fmt.Errorf("failed to parse public key: %w", err) 53 | } 54 | 55 | keyParts := strings.Split(string(ssh.MarshalAuthorizedKey(key)), " ") 56 | if len(keyParts) != 2 { 57 | return "", charm.ErrMalformedKey 58 | } 59 | 60 | b, err := base64.StdEncoding.DecodeString(keyParts[1]) 61 | if err != nil { 62 | return "", err 63 | } 64 | 65 | h := sha256.New() 66 | _, _ = h.Write(b) 67 | board := randomart.GenerateSubtitled( 68 | h.Sum(nil), 69 | fmt.Sprintf( 70 | "%s %d", 71 | strings.ToUpper(algo(key.Type())), 72 | bitsize(key.Type()), 73 | ), 74 | "SHA256", 75 | ).String() 76 | return strings.TrimSpace(board), nil 77 | } 78 | 79 | func algo(keyType string) string { 80 | if idx := strings.Index(keyType, "@"); idx > 0 { 81 | return algo(keyType[0:idx]) 82 | } 83 | parts := strings.Split(keyType, "-") 84 | if len(parts) == 2 { 85 | return parts[1] 86 | } 87 | if parts[0] == "sk" { 88 | return algo(strings.TrimPrefix(keyType, "sk-")) 89 | } 90 | return parts[0] 91 | } 92 | 93 | func bitsize(keyType string) int { 94 | switch keyType { 95 | case ssh.KeyAlgoED25519, ssh.KeyAlgoECDSA256, ssh.KeyAlgoSKECDSA256, ssh.KeyAlgoSKED25519: 96 | return 256 97 | case ssh.KeyAlgoECDSA384: 98 | return 384 99 | case ssh.KeyAlgoECDSA521: 100 | return 521 101 | case ssh.KeyAlgoDSA: 102 | return 1024 103 | case ssh.KeyAlgoRSA: 104 | return 3071 // usually 105 | default: 106 | return 0 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /client/keys_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "testing" 5 | 6 | "golang.org/x/crypto/ssh" 7 | ) 8 | 9 | func TestAlgo(t *testing.T) { 10 | for k, v := range map[string]string{ 11 | ssh.KeyAlgoRSA: "rsa", 12 | ssh.KeyAlgoDSA: "dss", 13 | ssh.KeyAlgoECDSA256: "ecdsa", 14 | ssh.KeyAlgoSKECDSA256: "ecdsa", 15 | ssh.KeyAlgoECDSA384: "ecdsa", 16 | ssh.KeyAlgoECDSA521: "ecdsa", 17 | ssh.KeyAlgoED25519: "ed25519", 18 | ssh.KeyAlgoSKED25519: "ed25519", 19 | } { 20 | t.Run(k, func(t *testing.T) { 21 | got := algo(k) 22 | if got != v { 23 | t.Errorf("expected %q, got %q", v, got) 24 | } 25 | }) 26 | } 27 | } 28 | 29 | func TestBitsize(t *testing.T) { 30 | for k, v := range map[string]int{ 31 | ssh.KeyAlgoRSA: 3071, 32 | ssh.KeyAlgoDSA: 1024, 33 | ssh.KeyAlgoECDSA256: 256, 34 | ssh.KeyAlgoSKECDSA256: 256, 35 | ssh.KeyAlgoECDSA384: 384, 36 | ssh.KeyAlgoECDSA521: 521, 37 | ssh.KeyAlgoED25519: 256, 38 | ssh.KeyAlgoSKED25519: 256, 39 | } { 40 | t.Run(k, func(t *testing.T) { 41 | got := bitsize(k) 42 | if got != v { 43 | t.Errorf("expected %d, got %d", v, got) 44 | } 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/link.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | charm "github.com/charmbracelet/charm/proto" 9 | ) 10 | 11 | // LinkGen initiates a linking session. 12 | func (cc *Client) LinkGen(lh charm.LinkHandler) error { 13 | s, err := cc.sshSession() 14 | if err != nil { 15 | return err 16 | } 17 | defer s.Close() // nolint:errcheck 18 | out, err := s.StdoutPipe() 19 | if err != nil { 20 | return err 21 | } 22 | in, err := s.StdinPipe() 23 | if err != nil { 24 | return err 25 | } 26 | err = s.Start("api-link") 27 | if err != nil { 28 | return err 29 | } 30 | 31 | // initialize link request on server 32 | var lr charm.Link 33 | dec := json.NewDecoder(out) 34 | err = dec.Decode(&lr) 35 | if err != nil { 36 | return err 37 | } 38 | if !checkLinkStatus(lh, &lr) { 39 | return nil 40 | } 41 | 42 | // waiting for link request, do we want to approve it? 43 | err = dec.Decode(&lr) 44 | if err != nil { 45 | return err 46 | } 47 | if !checkLinkStatus(lh, &lr) { 48 | return nil 49 | } 50 | 51 | // send approval response 52 | var lm charm.Message 53 | enc := json.NewEncoder(in) 54 | if lh.Request(&lr) { 55 | lm = charm.Message{Message: "yes"} 56 | } else { 57 | lm = charm.Message{Message: "no"} 58 | } 59 | err = enc.Encode(lm) 60 | if err != nil { 61 | return err 62 | } 63 | if lm.Message == "no" { 64 | return nil 65 | } 66 | 67 | // get server response 68 | err = dec.Decode(&lr) 69 | if err != nil { 70 | return err 71 | } 72 | err = cc.SyncEncryptKeys() 73 | if err != nil { 74 | return err 75 | } 76 | checkLinkStatus(lh, &lr) 77 | return nil 78 | } 79 | 80 | // Link joins in on a linking session initiated by LinkGen. 81 | func (cc *Client) Link(lh charm.LinkHandler, code string) error { 82 | s, err := cc.sshSession() 83 | if err != nil { 84 | return err 85 | } 86 | defer s.Close() // nolint:errcheck 87 | out, err := s.StdoutPipe() 88 | if err != nil { 89 | return err 90 | } 91 | err = s.Start(fmt.Sprintf("api-link %s", code)) 92 | if err != nil { 93 | return err 94 | } 95 | var lr charm.Link 96 | dec := json.NewDecoder(out) 97 | err = dec.Decode(&lr) // Start Request 98 | if err != nil { 99 | return err 100 | } 101 | if !checkLinkStatus(lh, &lr) { 102 | return nil 103 | } 104 | 105 | err = dec.Decode(&lr) // Token Check 106 | if err != nil { 107 | return err 108 | } 109 | if !checkLinkStatus(lh, &lr) { 110 | return nil 111 | } 112 | 113 | err = dec.Decode(&lr) // Results 114 | if err != nil { 115 | return err 116 | } 117 | err = cc.SyncEncryptKeys() 118 | if err != nil { 119 | return err 120 | } 121 | checkLinkStatus(lh, &lr) 122 | return nil 123 | } 124 | 125 | // SyncEncryptKeys re-encodes all of the encrypt keys associated for this 126 | // public key with all other linked public keys. 127 | func (cc *Client) SyncEncryptKeys() error { 128 | cc.InvalidateAuth() 129 | eks, err := cc.EncryptKeys() 130 | if err != nil { 131 | return err 132 | } 133 | cks, err := cc.AuthorizedKeysWithMetadata() 134 | if err != nil { 135 | return err 136 | } 137 | for _, k := range cks.Keys { 138 | for _, ek := range eks { 139 | err := cc.addEncryptKey(k.Key, ek.ID, ek.Key, ek.CreatedAt) 140 | if err != nil { 141 | return err 142 | } 143 | } 144 | } 145 | return cc.deleteUserData() 146 | } 147 | 148 | func (cc *Client) deleteUserData() error { 149 | // nolint: godox 150 | // TODO find a better place for this, or do something more sophisticated than 151 | // just wiping it out. 152 | dd, err := cc.DataPath() 153 | if err != nil { 154 | return err 155 | } 156 | // nolint: godox 157 | // TODO add any other directories that need wiping 158 | kvd := fmt.Sprintf("%s/kv", dd) 159 | return os.RemoveAll(kvd) 160 | } 161 | 162 | func checkLinkStatus(lh charm.LinkHandler, l *charm.Link) bool { 163 | switch l.Status { 164 | case charm.LinkStatusTokenCreated: 165 | lh.TokenCreated(l) 166 | case charm.LinkStatusTokenSent: 167 | lh.TokenSent(l) 168 | case charm.LinkStatusValidTokenRequest: 169 | lh.ValidToken(l) 170 | case charm.LinkStatusInvalidTokenRequest: 171 | lh.InvalidToken(l) 172 | return false 173 | case charm.LinkStatusRequestDenied: 174 | lh.RequestDenied(l) 175 | return false 176 | case charm.LinkStatusSameUser: 177 | lh.SameUser(l) 178 | case charm.LinkStatusSuccess: 179 | lh.Success(l) 180 | case charm.LinkStatusTimedOut: 181 | lh.Timeout(l) 182 | return false 183 | case charm.LinkStatusError: 184 | lh.Error(l) 185 | return false 186 | } 187 | return true 188 | } 189 | -------------------------------------------------------------------------------- /client/news.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | charm "github.com/charmbracelet/charm/proto" 9 | ) 10 | 11 | // NewsList lists the server news. 12 | func (cc *Client) NewsList(tags []string, page int) ([]*charm.News, error) { 13 | var nl []*charm.News 14 | 15 | if tags == nil { 16 | tags = []string{"server"} 17 | } 18 | tq := url.QueryEscape(strings.Join(tags, ",")) 19 | err := cc.AuthedJSONRequest("GET", fmt.Sprintf("/v1/news?page=%d&tags=%s", page, tq), nil, &nl) 20 | if err != nil { 21 | return nil, err 22 | } 23 | return nl, nil 24 | } 25 | 26 | // News shows a given news. 27 | func (cc *Client) News(id string) (*charm.News, error) { 28 | var n *charm.News 29 | err := cc.AuthedJSONRequest("GET", fmt.Sprintf("/v1/news/%s", url.QueryEscape(id)), nil, &n) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return n, nil 34 | } 35 | -------------------------------------------------------------------------------- /cmd/backup_keys.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "archive/tar" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strings" 11 | 12 | "github.com/charmbracelet/charm/client" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var backupOutputFile string 17 | 18 | func init() { 19 | BackupKeysCmd.Flags().StringVarP(&backupOutputFile, "output", "o", "charm-keys-backup.tar", "keys backup filepath") 20 | } 21 | 22 | // BackupKeysCmd is the cobra.Command to back up a user's account SSH keys. 23 | var BackupKeysCmd = &cobra.Command{ 24 | Use: "backup-keys", 25 | Hidden: false, 26 | Short: "Backup your Charm account keys", 27 | Long: paragraph(fmt.Sprintf("%s your Charm account keys to a tar archive file. \nYou can restore your keys from backup using import-keys. \nRun `charm import-keys -help` to learn more.", keyword("Backup"))), 28 | Args: cobra.NoArgs, 29 | DisableFlagsInUseLine: true, 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | cfg, err := client.ConfigFromEnv() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | cc, err := client.NewClient(cfg) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | dd, err := cc.DataPath() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | if err := validateDirectory(dd); err != nil { 47 | return err 48 | } 49 | 50 | backupPath := backupOutputFile 51 | if backupPath == "-" { 52 | exp := regexp.MustCompilePOSIX("charm_(rsa|ed25519)$") 53 | paths, err := getKeyPaths(dd, exp) 54 | if err != nil { 55 | return err 56 | } 57 | if len(paths) != 1 { 58 | return fmt.Errorf("backup to stdout only works with 1 key, you have %d", len(paths)) 59 | } 60 | bts, err := os.ReadFile(paths[0]) 61 | if err != nil { 62 | return err 63 | } 64 | _, _ = fmt.Fprint(cmd.OutOrStdout(), string(bts)) 65 | return nil 66 | } 67 | 68 | if !strings.HasSuffix(backupPath, ".tar") { 69 | backupPath = backupPath + ".tar" 70 | } 71 | 72 | if fileOrDirectoryExists(backupPath) { 73 | fmt.Printf("Not creating backup file: %s already exists.\n\n", code(backupPath)) 74 | os.Exit(1) 75 | } 76 | 77 | if err := os.MkdirAll(filepath.Dir(backupPath), 0o754); err != nil { 78 | return err 79 | } 80 | 81 | if err := createTar(dd, backupPath); err != nil { 82 | return err 83 | } 84 | 85 | fmt.Printf("Done! Saved keys to %s.\n\n", code(backupPath)) 86 | return nil 87 | }, 88 | } 89 | 90 | func fileOrDirectoryExists(path string) bool { 91 | if _, err := os.Stat(path); os.IsNotExist(err) { 92 | return false 93 | } 94 | return true 95 | } 96 | 97 | func validateDirectory(path string) error { 98 | info, err := os.Stat(path) 99 | if err == nil { 100 | if !info.IsDir() { 101 | return fmt.Errorf("%v is not a directory, but it should be", path) 102 | } 103 | 104 | files, err := os.ReadDir(path) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | foundKeys := 0 110 | keyPattern := regexp.MustCompile(`charm_(rsa|ed25519)(\.pub)?`) 111 | 112 | for _, f := range files { 113 | if !f.IsDir() && keyPattern.MatchString(f.Name()) { 114 | foundKeys++ 115 | } 116 | } 117 | if foundKeys < 2 { 118 | return fmt.Errorf("we didn’t find any keys to backup in %s", path) 119 | } 120 | 121 | // Everything looks OK! 122 | return nil 123 | } else if os.IsNotExist(err) { 124 | return fmt.Errorf("'%v' does not exist", path) 125 | } else { 126 | return err 127 | } 128 | } 129 | 130 | func createTar(source string, target string) error { 131 | tarfile, err := os.Create(target) 132 | if err != nil { 133 | return err 134 | } 135 | defer tarfile.Close() // nolint:errcheck 136 | 137 | tarball := tar.NewWriter(tarfile) 138 | defer tarball.Close() // nolint:errcheck 139 | 140 | info, err := os.Stat(source) 141 | if err != nil { 142 | return nil 143 | } 144 | 145 | var baseDir string 146 | if info.IsDir() { 147 | baseDir = filepath.Base(source) 148 | } 149 | 150 | exp := regexp.MustCompilePOSIX("charm_(rsa|ed25519)(.pub)?$") 151 | 152 | paths, err := getKeyPaths(source, exp) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | for _, path := range paths { 158 | info, err := os.Stat(path) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | header, err := tar.FileInfoHeader(info, info.Name()) 164 | if err != nil { 165 | return err 166 | } 167 | 168 | if baseDir != "" { 169 | header.Name = filepath.Join(baseDir, strings.TrimPrefix(path, source)) 170 | } 171 | 172 | if err := tarball.WriteHeader(header); err != nil { 173 | return err 174 | } 175 | 176 | if info.IsDir() { 177 | return nil 178 | } 179 | 180 | file, err := os.Open(path) 181 | if err != nil { 182 | return err 183 | } 184 | defer file.Close() // nolint:errcheck 185 | 186 | if _, err := io.Copy(tarball, file); err != nil { 187 | return err 188 | } 189 | if err := file.Close(); err != nil { 190 | return err 191 | } 192 | } 193 | 194 | if err := tarball.Close(); err != nil { 195 | return err 196 | } 197 | return tarfile.Close() 198 | } 199 | 200 | func getKeyPaths(source string, filter *regexp.Regexp) ([]string, error) { 201 | var result []string 202 | err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error { 203 | if err != nil { 204 | return err 205 | } 206 | 207 | if filter.MatchString(path) { 208 | result = append(result, path) 209 | } 210 | 211 | return nil 212 | }) 213 | return result, err 214 | } 215 | -------------------------------------------------------------------------------- /cmd/backup_keys_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/charmbracelet/charm/testserver" 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | func TestBackupKeysCmd(t *testing.T) { 16 | backupFilePath := "./charm-keys-backup.tar" 17 | _ = os.RemoveAll(backupFilePath) 18 | _ = testserver.SetupTestServer(t) 19 | 20 | if err := BackupKeysCmd.Execute(); err != nil { 21 | t.Fatalf("command failed: %s", err) 22 | } 23 | 24 | f, err := os.Open(backupFilePath) 25 | if err != nil { 26 | t.Fatalf("error opening tar file: %s", err) 27 | } 28 | t.Cleanup(func() { 29 | _ = f.Close() 30 | }) 31 | fi, err := f.Stat() 32 | if err != nil { 33 | t.Fatalf("error reading length of tar file: %s", err) 34 | } 35 | if fi.Size() <= 1024 { 36 | t.Errorf("tar file should not be empty") 37 | } 38 | 39 | var paths []string 40 | r := tar.NewReader(f) 41 | for { 42 | h, err := r.Next() 43 | if err == io.EOF { 44 | break 45 | } 46 | if err != nil { 47 | t.Errorf("error opening tar file: %s", err) 48 | } 49 | paths = append(paths, h.Name) 50 | 51 | if name := filepath.Base(h.Name); name != "charm_ed25519" && name != "charm_ed25519.pub" { 52 | t.Errorf("invalid file name: %q", name) 53 | } 54 | } 55 | 56 | if len(paths) != 2 { 57 | t.Errorf("expected at least 2 files (public and private keys), got %d: %v", len(paths), paths) 58 | } 59 | } 60 | 61 | func TestBackupToStdout(t *testing.T) { 62 | _ = testserver.SetupTestServer(t) 63 | var b bytes.Buffer 64 | 65 | BackupKeysCmd.SetArgs([]string{"-o", "-"}) 66 | BackupKeysCmd.SetOut(&b) 67 | if err := BackupKeysCmd.Execute(); err != nil { 68 | t.Fatalf("command failed: %s", err) 69 | } 70 | 71 | if _, err := ssh.ParsePrivateKey(b.Bytes()); err != nil { 72 | t.Fatalf("expected no error, got %v", err) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /cmd/bio.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // BioCmd is the cobra.Command to return a user's bio JSON result. 10 | var BioCmd = &cobra.Command{ 11 | Use: "bio", 12 | Hidden: true, 13 | Short: "", 14 | Long: "", 15 | Args: cobra.NoArgs, 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | cc := initCharmClient() 18 | u, err := cc.Bio() 19 | if err != nil { 20 | return err 21 | } 22 | 23 | fmt.Println(u) 24 | return nil 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | // Package cmd implements the Cobra commands for the charm CLI. 2 | package cmd 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | 8 | "github.com/charmbracelet/log" 9 | 10 | "github.com/charmbracelet/charm/client" 11 | charm "github.com/charmbracelet/charm/proto" 12 | "github.com/charmbracelet/charm/ui/common" 13 | ) 14 | 15 | var ( 16 | styles = common.DefaultStyles() 17 | paragraph = styles.Paragraph.Render 18 | keyword = styles.Keyword.Render 19 | code = styles.Code.Render 20 | subtle = styles.Subtle.Render 21 | ) 22 | 23 | func printFormatted(s string) { 24 | fmt.Println(paragraph(s) + "\n") 25 | } 26 | 27 | func getCharmConfig() *client.Config { 28 | cfg, err := client.ConfigFromEnv() 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | return cfg 34 | } 35 | 36 | func initCharmClient() *client.Client { 37 | cfg := getCharmConfig() 38 | cc, err := client.NewClient(cfg) 39 | if err == charm.ErrMissingSSHAuth { 40 | printFormatted("We were’t able to authenticate via SSH, which means there’s likely a problem with your key.\n\nYou can generate SSH keys by running " + code("charm keygen") + ". You can also set the environment variable " + code("CHARM_SSH_KEY_PATH") + " to point to a specific private key, or use " + code("-i") + "specifify a location.") 41 | os.Exit(1) 42 | } else if err != nil { 43 | fmt.Println(err) 44 | os.Exit(1) 45 | } 46 | return cc 47 | } 48 | -------------------------------------------------------------------------------- /cmd/completion.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func completionInstructions() string { 10 | return paragraph(`Charm supports ` + keyword("shell completion") + ` for bash, zsh, fish and powershell. 11 | 12 | ` + keyword("Bash") + ` 13 | 14 | To install completions: 15 | 16 | Linux (as root): 17 | $ charm completion bash > /etc/bash_completion.d/charm 18 | 19 | MacOS: 20 | $ charm completion bash > /usr/local/etc/bash_completion.d/charm 21 | 22 | Note that on macOS you'll need to have bash completion installed. The easiest 23 | way to do this is with Homewbrew. For more info run: brew info bash-completion. 24 | 25 | Or, to just load Charm completion for the current session: 26 | $ source <(charm completion bash) 27 | 28 | ` + keyword("Zsh") + ` 29 | 30 | If shell completion is not already enabled in your environment you will need to enable it. You can execute the following once: 31 | $ echo "autoload -U compinit; compinit" >> ~/.zshrc 32 | 33 | Then, to install completions: 34 | $ charm completion zsh > "${fpath[1]}/_charm" 35 | 36 | You will need to start a new shell for this setup to take effect. 37 | 38 | ` + keyword("Fish") + ` 39 | 40 | To load completions for each session: 41 | $ charm completion fish > ~/.config/fish/completions/charm.fish 42 | 43 | Or to just load in the current session: 44 | $ charm completion fish | source`) 45 | } 46 | 47 | // CompletionCmd is the cobra.Command to generate shell completion. 48 | var CompletionCmd = &cobra.Command{ 49 | Use: "completion [bash|zsh|fish|powershell]", 50 | Short: "Generate shell completion", 51 | Long: completionInstructions(), 52 | DisableFlagsInUseLine: true, 53 | ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, 54 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 55 | Run: func(cmd *cobra.Command, args []string) { 56 | switch args[0] { 57 | case "bash": 58 | cmd.Root().GenBashCompletion(os.Stdout) // nolint: errcheck 59 | case "zsh": 60 | cmd.Root().GenZshCompletion(os.Stdout) // nolint: errcheck 61 | case "fish": 62 | cmd.Root().GenFishCompletion(os.Stdout, true) // nolint: errcheck 63 | case "powershell": 64 | cmd.Root().GenPowerShellCompletion(os.Stdout) // nolint: errcheck 65 | } 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /cmd/crypt.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "os" 10 | 11 | "github.com/charmbracelet/charm/crypt" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var ( 16 | // CryptCmd is the cobra.Command to manage encryption and decryption for a user. 17 | CryptCmd = &cobra.Command{ 18 | Use: "crypt", 19 | Hidden: false, 20 | Short: "Use Charm encryption.", 21 | Long: styles.Paragraph.Render("Commands to encrypt and decrypt data with your Charm Cloud encryption keys."), 22 | Args: cobra.NoArgs, 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | return nil 25 | }, 26 | } 27 | 28 | cryptEncryptCmd = &cobra.Command{ 29 | Use: "encrypt", 30 | Hidden: false, 31 | Short: "Encrypt stdin with your Charm account encryption key", 32 | Args: cobra.NoArgs, 33 | RunE: cryptEncrypt, 34 | } 35 | 36 | cryptDecryptCmd = &cobra.Command{ 37 | Use: "decrypt", 38 | Hidden: false, 39 | Short: "Decrypt stdin with your Charm account encryption key", 40 | Args: cobra.RangeArgs(0, 1), 41 | RunE: cryptDecrypt, 42 | } 43 | 44 | cryptEncryptLookupCmd = &cobra.Command{ 45 | Use: "encrypt-lookup", 46 | Hidden: false, 47 | Short: "Encrypt arg deterministically", 48 | Args: cobra.ExactArgs(1), 49 | RunE: cryptEncryptLookup, 50 | } 51 | 52 | cryptDecryptLookupCmd = &cobra.Command{ 53 | Use: "decrypt-lookup", 54 | Hidden: false, 55 | Short: "Decrypt arg deterministically", 56 | Args: cobra.ExactArgs(1), 57 | RunE: cryptDecryptLookup, 58 | } 59 | ) 60 | 61 | type cryptFile struct { 62 | Data string `json:"data"` 63 | } 64 | 65 | func cryptEncrypt(_ *cobra.Command, _ []string) error { 66 | cr, err := crypt.NewCrypt() 67 | if err != nil { 68 | return err 69 | } 70 | buf := bytes.NewBuffer(nil) 71 | eb, err := cr.NewEncryptedWriter(buf) 72 | if err != nil { 73 | return err 74 | } 75 | _, err = io.Copy(eb, os.Stdin) 76 | if err != nil { 77 | return err 78 | } 79 | eb.Close() // nolint:errcheck 80 | cf := cryptFile{ 81 | Data: base64.StdEncoding.EncodeToString(buf.Bytes()), 82 | } 83 | out, err := json.Marshal(cf) 84 | if err != nil { 85 | return err 86 | } 87 | fmt.Println(string(out)) 88 | return nil 89 | } 90 | 91 | func cryptDecrypt(_ *cobra.Command, args []string) error { 92 | var r io.Reader 93 | cr, err := crypt.NewCrypt() 94 | if err != nil { 95 | return err 96 | } 97 | switch len(args) { 98 | case 0: 99 | r = os.Stdin 100 | default: 101 | f, err := os.Open(args[0]) 102 | defer f.Close() // nolint:errcheck 103 | r = f 104 | if err != nil { 105 | return err 106 | } 107 | } 108 | cf := &cryptFile{} 109 | jd := json.NewDecoder(r) 110 | err = jd.Decode(cf) 111 | if err != nil { 112 | return err 113 | } 114 | d, err := base64.StdEncoding.DecodeString(cf.Data) 115 | if err != nil { 116 | return err 117 | } 118 | br := bytes.NewReader(d) 119 | deb, err := cr.NewDecryptedReader(br) 120 | if err != nil { 121 | return err 122 | } 123 | _, err = io.Copy(os.Stdout, deb) 124 | if err != nil { 125 | return err 126 | } 127 | return nil 128 | } 129 | 130 | func cryptEncryptLookup(_ *cobra.Command, args []string) error { 131 | cr, err := crypt.NewCrypt() 132 | if err != nil { 133 | return err 134 | } 135 | ct, err := cr.EncryptLookupField(args[0]) 136 | if err != nil { 137 | return err 138 | } 139 | fmt.Println(ct) 140 | return nil 141 | } 142 | 143 | func cryptDecryptLookup(_ *cobra.Command, args []string) error { 144 | cr, err := crypt.NewCrypt() 145 | if err != nil { 146 | return err 147 | } 148 | pt, err := cr.DecryptLookupField(args[0]) 149 | if err != nil { 150 | return err 151 | } 152 | fmt.Println(pt) 153 | return nil 154 | } 155 | 156 | func init() { 157 | CryptCmd.AddCommand(cryptEncryptCmd) 158 | CryptCmd.AddCommand(cryptDecryptCmd) 159 | CryptCmd.AddCommand(cryptEncryptLookupCmd) 160 | CryptCmd.AddCommand(cryptDecryptLookupCmd) 161 | } 162 | -------------------------------------------------------------------------------- /cmd/fs.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | "text/tabwriter" 12 | 13 | cfs "github.com/charmbracelet/charm/fs" 14 | charm "github.com/charmbracelet/charm/proto" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | const ( 19 | localPath pathType = iota 20 | remotePath 21 | ) 22 | 23 | type pathType int 24 | 25 | type localRemotePath struct { 26 | pathType pathType 27 | path string 28 | } 29 | 30 | type localRemoteFS struct { 31 | cfs *cfs.FS 32 | } 33 | 34 | var ( 35 | isRecursive bool 36 | 37 | // FSCmd is the cobra.Command to use the Charm file system. 38 | FSCmd = &cobra.Command{ 39 | Use: "fs", 40 | Hidden: false, 41 | Short: "Use the Charm file system.", 42 | Long: paragraph("Commands to set, get and delete data from your Charm Cloud backed file system."), 43 | } 44 | 45 | fsCatCmd = &cobra.Command{ 46 | Use: "cat [charm:]PATH", 47 | Hidden: false, 48 | Short: "Output the content of the file at path.", 49 | Args: cobra.ExactArgs(1), 50 | RunE: fsCat, 51 | } 52 | 53 | fsCopyCmd = &cobra.Command{ 54 | Use: "cp [charm:]PATH [charm:]PATH", 55 | Hidden: false, 56 | Short: "Copy a file, preface source or destination with \"charm:\" to specify a remote path.", 57 | Args: cobra.ExactArgs(2), 58 | RunE: fsCopy, 59 | } 60 | 61 | fsRemoveCmd = &cobra.Command{ 62 | Use: "rm [charm:]PATH", 63 | Hidden: false, 64 | Short: "Remove file or directory at path", 65 | Args: cobra.ExactArgs(1), 66 | RunE: fsRemove, 67 | } 68 | 69 | fsMoveCmd = &cobra.Command{ 70 | Use: "mv [charm:]PATH [charm:]PATH", 71 | Hidden: false, 72 | Short: "Move a file, preface source or destination with \"charm:\" to specify a remote path.", 73 | Args: cobra.ExactArgs(2), 74 | RunE: fsMove, 75 | } 76 | 77 | fsListCmd = &cobra.Command{ 78 | Use: "ls [charm:]PATH", 79 | Hidden: false, 80 | Short: "List file or directory at path", 81 | Args: cobra.ExactArgs(1), 82 | RunE: fsList, 83 | } 84 | 85 | fsTreeCmd = &cobra.Command{ 86 | Use: "tree [charm:]PATH", 87 | Hidden: false, 88 | Short: "Print a file system tree from path.", 89 | Args: cobra.ExactArgs(1), 90 | RunE: fsTree, 91 | } 92 | ) 93 | 94 | func newLocalRemoteFS() (*localRemoteFS, error) { 95 | ccfs, err := cfs.NewFS() 96 | if err != nil { 97 | return nil, err 98 | } 99 | return &localRemoteFS{cfs: ccfs}, nil 100 | } 101 | 102 | func newLocalRemotePath(rawPath string) localRemotePath { 103 | var pt pathType 104 | var p string 105 | if strings.HasPrefix(rawPath, "charm:") { 106 | pt = remotePath 107 | p = rawPath[6:] 108 | } else { 109 | pt = localPath 110 | p = rawPath 111 | } 112 | return localRemotePath{ 113 | pathType: pt, 114 | path: p, 115 | } 116 | } 117 | 118 | func (lrp *localRemotePath) separator() string { // nolint:unparam 119 | switch lrp.pathType { 120 | case localPath: 121 | return string(os.PathSeparator) 122 | default: 123 | return "/" 124 | } 125 | } 126 | 127 | func (lrfs *localRemoteFS) Open(name string) (fs.File, error) { 128 | p := newLocalRemotePath(name) 129 | switch p.pathType { 130 | case localPath: 131 | return os.Open(p.path) 132 | case remotePath: 133 | return lrfs.cfs.Open(p.path) 134 | default: 135 | return nil, fmt.Errorf("invalid path type") 136 | } 137 | } 138 | 139 | func (lrfs *localRemoteFS) ReadDir(name string) ([]fs.DirEntry, error) { 140 | p := newLocalRemotePath(name) 141 | switch p.pathType { 142 | case localPath: 143 | return os.ReadDir(p.path) 144 | case remotePath: 145 | return lrfs.cfs.ReadDir(p.path) 146 | default: 147 | return nil, fmt.Errorf("invalid path type") 148 | } 149 | } 150 | 151 | func (lrfs *localRemoteFS) write(name string, src fs.File) error { 152 | stat, err := src.Stat() 153 | if err != nil { 154 | return err 155 | } 156 | p := newLocalRemotePath(name) 157 | switch p.pathType { 158 | case localPath: 159 | dir := filepath.Dir(p.path) 160 | if stat.IsDir() { 161 | dir = dir + "/" 162 | } 163 | err = os.MkdirAll(dir, charm.AddExecPermsForMkDir(stat.Mode())) 164 | if err != nil { 165 | return err 166 | } 167 | if !stat.IsDir() { 168 | f, err := os.OpenFile(p.path, os.O_RDWR|os.O_CREATE, stat.Mode().Perm()) 169 | if err != nil { 170 | return err 171 | } 172 | defer f.Close() // nolint:errcheck 173 | _, err = io.Copy(f, src) 174 | if err != nil { 175 | return err 176 | } 177 | } 178 | case remotePath: 179 | if !stat.IsDir() { 180 | return lrfs.cfs.WriteFile(p.path, src) 181 | } 182 | default: 183 | return fmt.Errorf("invalid path type") 184 | } 185 | return nil 186 | } 187 | 188 | func (lrfs *localRemoteFS) copy(srcName string, dstName string, recursive bool) error { 189 | src, err := lrfs.Open(srcName) 190 | if err != nil { 191 | return err 192 | } 193 | defer src.Close() // nolint:errcheck 194 | stat, err := src.Stat() 195 | if err != nil { 196 | return err 197 | } 198 | if stat.IsDir() && !recursive { 199 | return fmt.Errorf("recursive copy not specified, omitting directory '%s'", srcName) 200 | } 201 | if stat.IsDir() && recursive { 202 | dp := newLocalRemotePath(dstName) 203 | dstRoot := filepath.Clean(dstName) + dp.separator() 204 | sp := newLocalRemotePath(srcName) 205 | parents := len(strings.Split(filepath.Clean(sp.path), sp.separator())) - 1 206 | return fs.WalkDir(lrfs, srcName, func(wps string, d fs.DirEntry, err error) error { 207 | if err != nil { 208 | fmt.Printf("error walking directory %s: %s", srcName, err) 209 | return err 210 | } 211 | wsrc, err := lrfs.Open(wps) 212 | if err != nil { 213 | return err 214 | } 215 | defer wsrc.Close() // nolint:errcheck 216 | wp := newLocalRemotePath(wps) 217 | wpp := strings.Split(filepath.Clean(wp.path), wp.separator()) 218 | rp := path.Join(wpp[parents:]...) 219 | return lrfs.write(path.Join(dstRoot, rp), wsrc) 220 | }) 221 | } 222 | return lrfs.write(dstName, src) 223 | } 224 | 225 | func fsCat(_ *cobra.Command, args []string) error { 226 | lsfs, err := cfs.NewFS() 227 | if err != nil { 228 | return err 229 | } 230 | f, err := lsfs.Open(args[0]) 231 | if err != nil { 232 | return err 233 | } 234 | defer f.Close() // nolint:errcheck 235 | 236 | fi, err := f.Stat() 237 | if err != nil { 238 | return err 239 | } 240 | 241 | if fi.IsDir() { 242 | fmt.Printf("cat: %s: Is a directory\n", args[0]) 243 | return nil 244 | } 245 | 246 | _, err = io.Copy(os.Stdout, f) 247 | return err 248 | } 249 | 250 | func fsMove(cmd *cobra.Command, args []string) error { 251 | if err := fsCopy(cmd, args); err != nil { 252 | return err 253 | } 254 | return fsRemove(cmd, args[:1]) 255 | } 256 | 257 | func fsRemove(_ *cobra.Command, args []string) error { 258 | lsfs, err := cfs.NewFS() 259 | if err != nil { 260 | return err 261 | } 262 | return lsfs.Remove(args[0]) 263 | } 264 | 265 | func fsCopy(_ *cobra.Command, args []string) error { 266 | lrfs, err := newLocalRemoteFS() 267 | if err != nil { 268 | return err 269 | } 270 | 271 | src := args[0] 272 | dst := args[1] 273 | if strings.HasPrefix(src, "charm:") { 274 | return lrfs.copy(src, dst, isRecursive) 275 | } 276 | 277 | // `charm fs cp foo charm:` will copy foo to charm:/foo 278 | srcInfo, err := os.Stat(src) 279 | if err != nil { 280 | return err 281 | } 282 | 283 | if !srcInfo.IsDir() && (dst == "charm:" || dst == "charm:/") { 284 | dst = "charm:/" + filepath.Base(src) 285 | } 286 | 287 | return lrfs.copy(src, dst, isRecursive) 288 | } 289 | 290 | func fsList(_ *cobra.Command, args []string) error { 291 | lsfs, err := cfs.NewFS() 292 | if err != nil { 293 | return err 294 | } 295 | f, err := lsfs.Open(args[0]) 296 | if err != nil { 297 | return err 298 | } 299 | defer f.Close() // nolint:errcheck 300 | fi, err := f.Stat() 301 | if err != nil { 302 | return err 303 | } 304 | if fi.IsDir() { 305 | err = printDir(f.(*cfs.File)) 306 | if err != nil { 307 | return err 308 | } 309 | } else { 310 | printFileInfo(fi) 311 | } 312 | return nil 313 | } 314 | 315 | func fsTree(_ *cobra.Command, args []string) error { 316 | lsfs, err := cfs.NewFS() 317 | if err != nil { 318 | return err 319 | } 320 | err = fs.WalkDir(lsfs, args[0], func(path string, d fs.DirEntry, err error) error { 321 | fmt.Println(path) 322 | return nil 323 | }) 324 | if err != nil { 325 | return err 326 | } 327 | return nil 328 | } 329 | 330 | func printFileInfo(fi fs.FileInfo) { 331 | fmt.Printf("%s %d %s %s\n", fi.Mode(), fi.Size(), fi.ModTime().Format("Jan 2 15:04"), fi.Name()) 332 | } 333 | 334 | func fprintFileInfo(w io.Writer, fi fs.FileInfo) { 335 | fmt.Fprintf(w, "%s\t%d\t%s\t %s\n", fi.Mode(), fi.Size(), fi.ModTime().Format("Jan _2 15:04"), fi.Name()) 336 | } 337 | 338 | func printDir(f fs.ReadDirFile) error { 339 | des, err := f.ReadDir(0) 340 | if err != nil { 341 | return err 342 | } 343 | w := new(tabwriter.Writer) 344 | w.Init(os.Stdout, 0, 1, 1, ' ', tabwriter.AlignRight) 345 | for _, v := range des { 346 | dfi, err := v.Info() 347 | if err != nil { 348 | return err 349 | } 350 | fprintFileInfo(w, dfi) 351 | } 352 | return w.Flush() 353 | } 354 | 355 | func init() { 356 | fsCopyCmd.Flags().BoolVarP(&isRecursive, "recursive", "r", false, "copy directories recursively") 357 | fsMoveCmd.Flags().BoolVarP(&isRecursive, "recursive", "r", false, "move directories recursively") 358 | 359 | FSCmd.AddCommand(fsCatCmd) 360 | FSCmd.AddCommand(fsCopyCmd) 361 | FSCmd.AddCommand(fsRemoveCmd) 362 | FSCmd.AddCommand(fsMoveCmd) 363 | FSCmd.AddCommand(fsListCmd) 364 | FSCmd.AddCommand(fsTreeCmd) 365 | } 366 | -------------------------------------------------------------------------------- /cmd/id.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // IDCmd is the cobra.Command to print a user's Charm ID. 10 | var IDCmd = &cobra.Command{ 11 | Use: "id", 12 | Short: "Print your Charm ID", 13 | Long: paragraph("Want to know your " + keyword("Charm ID") + "? You’re in luck, kiddo."), 14 | Args: cobra.NoArgs, 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | cc := initCharmClient() 17 | id, err := cc.ID() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | fmt.Println(id) 23 | return nil 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /cmd/import_keys.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "archive/tar" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/charm/client" 12 | "github.com/charmbracelet/charm/ui/common" 13 | "github.com/spf13/cobra" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | type ( 18 | confirmationState int 19 | confirmationSuccessMsg struct{} 20 | confirmationErrMsg struct{ error } 21 | ) 22 | 23 | const ( 24 | ready confirmationState = iota 25 | confirmed 26 | cancelling 27 | success 28 | fail 29 | ) 30 | 31 | var ( 32 | forceImportOverwrite bool 33 | 34 | // ImportKeysCmd is the cobra.Command to import a user's ssh key backup as creaed by `backup-keys`. 35 | ImportKeysCmd = &cobra.Command{ 36 | Use: "import-keys BACKUP.tar", 37 | Hidden: false, 38 | Short: "Import previously backed up Charm account keys.", 39 | Long: paragraph(fmt.Sprintf("%s previously backed up Charm account keys.", keyword("Import"))), 40 | Args: cobra.MaximumNArgs(1), 41 | DisableFlagsInUseLine: false, 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | cfg, err := client.ConfigFromEnv() 44 | if err != nil { 45 | return err 46 | } 47 | cc, err := client.NewClient(cfg) 48 | if err != nil { 49 | return err 50 | } 51 | dd, err := cc.DataPath() 52 | if err != nil { 53 | return err 54 | } 55 | 56 | if err := os.MkdirAll(dd, 0o700); err != nil { 57 | return err 58 | } 59 | 60 | empty, err := isEmpty(dd) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | path := "-" 66 | if len(args) > 0 { 67 | path = args[0] 68 | } 69 | if !empty && !forceImportOverwrite { 70 | if common.IsTTY() { 71 | p := newImportConfirmationTUI(cmd.InOrStdin(), path, dd) 72 | if _, err := p.Run(); err != nil { 73 | return err 74 | } 75 | return nil 76 | } 77 | return fmt.Errorf("not overwriting the existing keys in %s; to force, use -f", dd) 78 | } 79 | 80 | if isStdin(path) { 81 | if err := restoreFromReader(cmd.InOrStdin(), dd); err != nil { 82 | return err 83 | } 84 | } else { 85 | if err := untar(path, dd); err != nil { 86 | return err 87 | } 88 | } 89 | 90 | paragraph(fmt.Sprintf("Done! Keys imported to %s", code(dd))) 91 | return nil 92 | }, 93 | } 94 | ) 95 | 96 | func isStdin(path string) bool { 97 | fi, _ := os.Stdin.Stat() 98 | return (fi.Mode()&os.ModeNamedPipe) != 0 || path == "-" 99 | } 100 | 101 | func restoreCmd(r io.Reader, path, dataPath string) tea.Cmd { 102 | return func() tea.Msg { 103 | if isStdin(path) { 104 | if err := restoreFromReader(r, dataPath); err != nil { 105 | return confirmationErrMsg{err} 106 | } 107 | return confirmationSuccessMsg{} 108 | } 109 | 110 | if err := untar(path, dataPath); err != nil { 111 | return confirmationErrMsg{err} 112 | } 113 | return confirmationSuccessMsg{} 114 | } 115 | } 116 | 117 | type confirmationTUI struct { 118 | reader io.Reader 119 | state confirmationState 120 | yes bool 121 | err error 122 | path, dataPath string 123 | } 124 | 125 | func (m confirmationTUI) Init() tea.Cmd { 126 | return nil 127 | } 128 | 129 | func (m confirmationTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 130 | switch msg := msg.(type) { 131 | case tea.KeyMsg: 132 | switch msg.String() { 133 | case "ctrl+c": 134 | m.state = cancelling 135 | return m, tea.Quit 136 | case "left", "h": 137 | m.yes = !m.yes 138 | case "right", "l": 139 | m.yes = !m.yes 140 | case "enter": 141 | if m.yes { 142 | m.state = confirmed 143 | return m, restoreCmd(m.reader, m.path, m.dataPath) 144 | } 145 | m.state = cancelling 146 | return m, tea.Quit 147 | case "y": 148 | m.yes = true 149 | m.state = confirmed 150 | return m, restoreCmd(m.reader, m.path, m.dataPath) 151 | default: 152 | if m.state == ready { 153 | m.yes = false 154 | m.state = cancelling 155 | return m, tea.Quit 156 | } 157 | } 158 | case confirmationSuccessMsg: 159 | m.state = success 160 | return m, tea.Quit 161 | case confirmationErrMsg: 162 | m.state = fail 163 | m.err = msg 164 | return m, tea.Quit 165 | } 166 | return m, nil 167 | } 168 | 169 | func (m confirmationTUI) View() string { 170 | var s string 171 | switch m.state { 172 | case ready: 173 | s = fmt.Sprintf("Looks like you might have some existing keys in %s\n\nWould you like to overwrite them?\n\n", code(m.dataPath)) 174 | s += common.YesButtonView(m.yes) + " " + common.NoButtonView(!m.yes) 175 | case success: 176 | s += fmt.Sprintf("Done! Key imported to %s", code(m.dataPath)) 177 | case fail: 178 | s = m.err.Error() 179 | case cancelling: 180 | s = "Ok, we won’t do anything. Bye!" 181 | } 182 | 183 | return paragraph(s) + "\n\n" 184 | } 185 | 186 | func isEmpty(name string) (bool, error) { 187 | f, err := os.Open(name) 188 | if err != nil { 189 | return false, err 190 | } 191 | defer f.Close() // nolint:errcheck 192 | 193 | _, err = f.Readdirnames(1) 194 | if err == io.EOF { 195 | return true, nil 196 | } 197 | return false, err 198 | } 199 | 200 | func restoreFromReader(r io.Reader, dd string) error { 201 | bts, err := io.ReadAll(r) 202 | if err != nil { 203 | return err 204 | } 205 | 206 | signer, err := ssh.ParsePrivateKey(bts) 207 | if err != nil { 208 | return fmt.Errorf("invalid private key: %w", err) 209 | } 210 | 211 | if signer.PublicKey().Type() != "ssh-ed25519" { 212 | return fmt.Errorf("only ed25519 keys are allowed, yours is %s", signer.PublicKey().Type()) 213 | } 214 | 215 | keypath := filepath.Join(dd, "charm_ed25519") 216 | if err := os.WriteFile(keypath, bts, 0o600); err != nil { 217 | return err 218 | } 219 | 220 | return os.WriteFile( 221 | keypath+".pub", 222 | ssh.MarshalAuthorizedKey(signer.PublicKey()), 223 | 0o600, 224 | ) 225 | } 226 | 227 | func untar(tarball, targetDir string) error { 228 | reader, err := os.Open(tarball) 229 | if err != nil { 230 | return err 231 | } 232 | defer reader.Close() // nolint:errcheck 233 | tarReader := tar.NewReader(reader) 234 | 235 | for { 236 | header, err := tarReader.Next() 237 | if err == io.EOF { 238 | break 239 | } else if err != nil { 240 | return err 241 | } 242 | 243 | // Files are stored in a 'charm' subdirectory in the tar. Strip off the 244 | // directory info so we can just place the files at the top level of 245 | // the given target directory. 246 | filename := filepath.Base(header.Name) 247 | 248 | // Don't create an empty "charm" directory 249 | if filename == "charm" { 250 | continue 251 | } 252 | 253 | path := filepath.Join(targetDir, filename) 254 | info := header.FileInfo() 255 | if info.IsDir() { 256 | if err = os.MkdirAll(path, info.Mode()); err != nil { 257 | return err 258 | } 259 | continue 260 | } 261 | 262 | file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) 263 | if err != nil { 264 | return err 265 | } 266 | defer file.Close() // nolint:errcheck 267 | 268 | for { 269 | _, err := io.CopyN(file, tarReader, 1024) 270 | if err != nil { 271 | if err == io.EOF { 272 | break 273 | } 274 | return err 275 | } 276 | } 277 | } 278 | return nil 279 | } 280 | 281 | // Import Confirmation TUI 282 | 283 | func newImportConfirmationTUI(r io.Reader, tarPath, dataPath string) *tea.Program { 284 | return tea.NewProgram(confirmationTUI{ 285 | reader: r, 286 | state: ready, 287 | path: tarPath, 288 | dataPath: dataPath, 289 | }) 290 | } 291 | 292 | func init() { 293 | ImportKeysCmd.Flags().BoolVarP(&forceImportOverwrite, "force-overwrite", "f", false, "overwrite if keys exist; don’t prompt for input") 294 | } 295 | -------------------------------------------------------------------------------- /cmd/import_keys_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/charmbracelet/charm/testserver" 10 | ) 11 | 12 | func TestImportKeysFromStdin(t *testing.T) { 13 | c := testserver.SetupTestServer(t) 14 | 15 | var r bytes.Buffer 16 | BackupKeysCmd.SetArgs([]string{"-o", "-"}) 17 | BackupKeysCmd.SetOut(&r) 18 | if err := BackupKeysCmd.Execute(); err != nil { 19 | t.Fatalf(err.Error()) 20 | } 21 | 22 | dd, _ := c.DataPath() 23 | if err := os.RemoveAll(dd); err != nil { 24 | t.Fatalf(err.Error()) 25 | } 26 | 27 | ImportKeysCmd.SetIn(&r) 28 | ImportKeysCmd.SetArgs([]string{"-f", "-"}) 29 | if err := ImportKeysCmd.Execute(); err != nil { 30 | t.Fatalf(err.Error()) 31 | } 32 | 33 | if _, err := os.Stat(filepath.Join(dd, "charm_ed25519")); err != nil { 34 | t.Fatalf(err.Error()) 35 | } 36 | if _, err := os.Stat(filepath.Join(dd, "charm_ed25519.pub")); err != nil { 37 | t.Fatalf(err.Error()) 38 | } 39 | } 40 | 41 | func TestImportKeysFromFile(t *testing.T) { 42 | c := testserver.SetupTestServer(t) 43 | 44 | f := filepath.Join(t.TempDir(), "backup.tar") 45 | 46 | BackupKeysCmd.SetArgs([]string{"-o", f}) 47 | if err := BackupKeysCmd.Execute(); err != nil { 48 | t.Fatalf(err.Error()) 49 | } 50 | 51 | dd, _ := c.DataPath() 52 | if err := os.RemoveAll(dd); err != nil { 53 | t.Fatalf(err.Error()) 54 | } 55 | 56 | ImportKeysCmd.SetArgs([]string{"-f", f}) 57 | if err := ImportKeysCmd.Execute(); err != nil { 58 | t.Fatalf(err.Error()) 59 | } 60 | 61 | if _, err := os.Stat(filepath.Join(dd, "charm_ed25519")); err != nil { 62 | t.Fatalf(err.Error()) 63 | } 64 | if _, err := os.Stat(filepath.Join(dd, "charm_ed25519.pub")); err != nil { 65 | t.Fatalf(err.Error()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /cmd/jwt.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // JWTCmd is the cobra.Command that prints a user's JWT token. 10 | var JWTCmd = &cobra.Command{ 11 | Use: "jwt", 12 | Short: "Print a JWT", 13 | Long: paragraph(keyword("JSON Web Tokens") + " are a way to authenticate to different services that utilize your Charm account. Use " + code("jwt") + " to get one for your account."), 14 | Args: cobra.ArbitraryArgs, 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | cc := initCharmClient() 17 | jwt, err := cc.JWT(args...) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | fmt.Printf("%s\n", jwt) 23 | return nil 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /cmd/keys.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/charm/client" 8 | "github.com/charmbracelet/charm/ui/common" 9 | "github.com/charmbracelet/charm/ui/keys" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var ( 14 | randomart bool 15 | simpleOutput bool 16 | ) 17 | 18 | // KeysCmd is the cobra.Command for a user to browser and print their linked 19 | // SSH keys. 20 | var KeysCmd = &cobra.Command{ 21 | Use: "keys", 22 | Short: "Browse or print linked SSH keys", 23 | Long: paragraph("Charm accounts are powered by " + keyword("SSH keys") + ". This command prints all of the keys linked to your account. To remove keys use the main " + code("charm") + " interface."), 24 | Args: cobra.NoArgs, 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | if common.IsTTY() && !randomart && !simpleOutput { 27 | // Log to file, if set 28 | cfg := getCharmConfig() 29 | if cfg.Logfile != "" { 30 | f, err := tea.LogToFile(cfg.Logfile, "charm") 31 | if err != nil { 32 | return err 33 | } 34 | defer f.Close() // nolint:errcheck 35 | } 36 | p := keys.NewProgram(cfg) 37 | if _, err := p.Run(); err != nil { 38 | return err 39 | } 40 | return nil 41 | } 42 | cc := initCharmClient() 43 | 44 | // Print randomart with fingerprints 45 | k, err := cc.AuthorizedKeysWithMetadata() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | keys := k.Keys 51 | for i := 0; i < len(keys); i++ { 52 | if !randomart { 53 | fmt.Println(keys[i].Key) 54 | continue 55 | } 56 | fp, err := client.FingerprintSHA256(*keys[i]) 57 | if err != nil { 58 | fp.Value = fmt.Sprintf("Could not generate fingerprint for key %s: %v\n\n", keys[i].Key, err) 59 | } 60 | board, err := client.RandomArt(*keys[i]) 61 | if err != nil { 62 | board = fmt.Sprintf("Could not generate randomart for key %s: %v\n\n", keys[i].Key, err) 63 | } 64 | cr := "\n\n" 65 | if i == len(keys)-1 { 66 | cr = "\n" 67 | } 68 | fmt.Printf("%s\n%s%s", fp, board, cr) 69 | } 70 | return nil 71 | }, 72 | } 73 | 74 | func init() { 75 | KeysCmd.Flags().BoolVarP(&simpleOutput, "simple", "s", false, "simple, non-interactive output (good for scripts)") 76 | KeysCmd.Flags().BoolVarP(&randomart, "randomart", "r", false, "print SSH 5.1 randomart for each key (the Drunken Bishop algorithm)") 77 | } 78 | -------------------------------------------------------------------------------- /cmd/keysync.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // KeySyncCmd is the cobra.Command to rencrypt and sync all encrypt keys for a 10 | // user. 11 | var KeySyncCmd = &cobra.Command{ 12 | Use: "sync-keys", 13 | Hidden: true, 14 | Short: "Re-encrypt encrypt keys for all linked public keys", 15 | Long: paragraph(fmt.Sprintf("%s encrypt keys for all linked public keys", keyword("Re-encrypt"))), 16 | Args: cobra.NoArgs, 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | cc := initCharmClient() 19 | return cc.SyncEncryptKeys() 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /cmd/kv.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "unicode/utf8" 8 | 9 | "github.com/charmbracelet/charm/kv" 10 | "github.com/charmbracelet/charm/ui/common" 11 | badger "github.com/dgraph-io/badger/v3" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var ( 16 | reverseIterate bool 17 | keysIterate bool 18 | valuesIterate bool 19 | showBinary bool 20 | delimiterIterate string 21 | 22 | // KVCmd is the cobra.Command for a user to use the Charm key value store. 23 | KVCmd = &cobra.Command{ 24 | Use: "kv", 25 | Hidden: false, 26 | Short: "Use the Charm key value store.", 27 | Long: paragraph("Commands to set, get and delete data from your Charm Cloud backed key value store."), 28 | Args: cobra.NoArgs, 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | return nil 31 | }, 32 | } 33 | 34 | kvSetCmd = &cobra.Command{ 35 | Use: "set KEY[@DB] VALUE", 36 | Hidden: false, 37 | Short: "Set a value for a key with an optional @ db.", 38 | Args: cobra.MaximumNArgs(2), 39 | RunE: kvSet, 40 | } 41 | 42 | kvGetCmd = &cobra.Command{ 43 | Use: "get KEY[@DB]", 44 | Hidden: false, 45 | Short: "Get a value for a key with an optional @ db.", 46 | Args: cobra.ExactArgs(1), 47 | RunE: kvGet, 48 | } 49 | 50 | kvDeleteCmd = &cobra.Command{ 51 | Use: "delete KEY[@DB]", 52 | Hidden: false, 53 | Short: "Delete a key with an optional @ db.", 54 | Args: cobra.ExactArgs(1), 55 | RunE: kvDelete, 56 | } 57 | 58 | kvListCmd = &cobra.Command{ 59 | Use: "list [@DB]", 60 | Hidden: false, 61 | Short: "List all key value pairs with an optional @ db.", 62 | Args: cobra.MaximumNArgs(1), 63 | RunE: kvList, 64 | } 65 | 66 | kvSyncCmd = &cobra.Command{ 67 | Use: "sync [@DB]", 68 | Hidden: false, 69 | Short: "Sync local db with latest Charm Cloud db.", 70 | Args: cobra.MaximumNArgs(1), 71 | RunE: kvSync, 72 | } 73 | 74 | kvResetCmd = &cobra.Command{ 75 | Use: "reset [@DB]", 76 | Hidden: false, 77 | Short: "Delete local db and pull down fresh copy from Charm Cloud.", 78 | Args: cobra.MaximumNArgs(1), 79 | RunE: kvReset, 80 | } 81 | ) 82 | 83 | func kvSet(_ *cobra.Command, args []string) error { 84 | k, n, err := keyParser(args[0]) 85 | if err != nil { 86 | return err 87 | } 88 | db, err := openKV(n) 89 | if err != nil { 90 | return err 91 | } 92 | if len(args) == 2 { 93 | return db.Set(k, []byte(args[1])) 94 | } 95 | return db.SetReader(k, os.Stdin) 96 | } 97 | 98 | func kvGet(_ *cobra.Command, args []string) error { 99 | k, n, err := keyParser(args[0]) 100 | if err != nil { 101 | return err 102 | } 103 | db, err := openKV(n) 104 | if err != nil { 105 | return err 106 | } 107 | v, err := db.Get(k) 108 | if err != nil { 109 | return err 110 | } 111 | printFromKV("%s", v) 112 | return nil 113 | } 114 | 115 | func kvDelete(_ *cobra.Command, args []string) error { 116 | k, n, err := keyParser(args[0]) 117 | if err != nil { 118 | return err 119 | } 120 | db, err := openKV(n) 121 | if err != nil { 122 | return err 123 | } 124 | return db.Delete(k) 125 | } 126 | 127 | func kvList(_ *cobra.Command, args []string) error { 128 | var k string 129 | var pf string 130 | if keysIterate || valuesIterate { 131 | pf = "%s\n" 132 | } else { 133 | pf = fmt.Sprintf("%%s%s%%s\n", delimiterIterate) 134 | } 135 | if len(args) == 1 { 136 | k = args[0] 137 | } 138 | _, n, err := keyParser(k) 139 | if err != nil { 140 | return err 141 | } 142 | db, err := openKV(n) 143 | if err != nil { 144 | return err 145 | } 146 | if err := db.Sync(); err != nil { 147 | return err 148 | } 149 | return db.View(func(txn *badger.Txn) error { 150 | opts := badger.DefaultIteratorOptions 151 | opts.PrefetchSize = 10 152 | opts.Reverse = reverseIterate 153 | if keysIterate { 154 | opts.PrefetchValues = false 155 | } 156 | it := txn.NewIterator(opts) 157 | defer it.Close() //nolint:errcheck 158 | for it.Rewind(); it.Valid(); it.Next() { 159 | item := it.Item() 160 | k := item.Key() 161 | if keysIterate { 162 | printFromKV(pf, k) 163 | continue 164 | } 165 | err := item.Value(func(v []byte) error { 166 | if valuesIterate { 167 | printFromKV(pf, v) 168 | } else { 169 | printFromKV(pf, k, v) 170 | } 171 | return nil 172 | }) 173 | if err != nil { 174 | return err 175 | } 176 | } 177 | return nil 178 | }) 179 | } 180 | 181 | func kvSync(_ *cobra.Command, args []string) error { 182 | n, err := nameFromArgs(args) 183 | if err != nil { 184 | return err 185 | } 186 | db, err := openKV(n) 187 | if err != nil { 188 | return err 189 | } 190 | return db.Sync() 191 | } 192 | 193 | func kvReset(_ *cobra.Command, args []string) error { 194 | n, err := nameFromArgs(args) 195 | if err != nil { 196 | return err 197 | } 198 | db, err := openKV(n) 199 | if err != nil { 200 | return err 201 | } 202 | return db.Reset() 203 | } 204 | 205 | func nameFromArgs(args []string) (string, error) { 206 | if len(args) == 0 { 207 | return "", nil 208 | } 209 | _, n, err := keyParser(args[0]) 210 | if err != nil { 211 | return "", err 212 | } 213 | return n, nil 214 | } 215 | 216 | func printFromKV(pf string, vs ...[]byte) { 217 | nb := "(omitted binary data)" 218 | fvs := make([]interface{}, 0) 219 | for _, v := range vs { 220 | if common.IsTTY() && !showBinary && !utf8.Valid(v) { 221 | fvs = append(fvs, nb) 222 | } else { 223 | fvs = append(fvs, string(v)) 224 | } 225 | } 226 | fmt.Printf(pf, fvs...) 227 | if common.IsTTY() && !strings.HasSuffix(pf, "\n") { 228 | fmt.Println() 229 | } 230 | } 231 | 232 | func keyParser(k string) ([]byte, string, error) { 233 | var key, db string 234 | ps := strings.Split(k, "@") 235 | switch len(ps) { 236 | case 1: 237 | key = strings.ToLower(ps[0]) 238 | case 2: 239 | key = strings.ToLower(ps[0]) 240 | db = strings.ToLower(ps[1]) 241 | default: 242 | return nil, "", fmt.Errorf("bad key format, use KEY@DB") 243 | } 244 | return []byte(key), db, nil 245 | } 246 | 247 | func openKV(name string) (*kv.KV, error) { 248 | if name == "" { 249 | name = "charm.sh.kv.user.default" 250 | } 251 | return kv.OpenWithDefaults(name) 252 | } 253 | 254 | func init() { 255 | kvListCmd.Flags().BoolVarP(&reverseIterate, "reverse", "r", false, "list in reverse lexicographic order") 256 | kvListCmd.Flags().BoolVarP(&keysIterate, "keys-only", "k", false, "only print keys and don't fetch values from the db") 257 | kvListCmd.Flags().BoolVarP(&valuesIterate, "values-only", "v", false, "only print values") 258 | kvListCmd.Flags().BoolVarP(&showBinary, "show-binary", "b", false, "print binary values") 259 | kvGetCmd.Flags().BoolVarP(&showBinary, "show-binary", "b", false, "print binary values") 260 | kvListCmd.Flags().StringVarP(&delimiterIterate, "delimiter", "d", "\t", "delimiter to separate keys and values") 261 | 262 | KVCmd.AddCommand(kvGetCmd) 263 | KVCmd.AddCommand(kvSetCmd) 264 | KVCmd.AddCommand(kvDeleteCmd) 265 | KVCmd.AddCommand(kvListCmd) 266 | KVCmd.AddCommand(kvSyncCmd) 267 | KVCmd.AddCommand(kvResetCmd) 268 | } 269 | -------------------------------------------------------------------------------- /cmd/link.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/charm/ui/link" 8 | "github.com/charmbracelet/charm/ui/linkgen" 9 | "github.com/muesli/reflow/indent" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // LinkCmd is the cobra.Command to manage user account linking. Pass the name 14 | // of the parent command. 15 | func LinkCmd(parentName string) *cobra.Command { 16 | return &cobra.Command{ 17 | Use: "link [code]", 18 | Short: "Link multiple machines to your Charm account", 19 | Long: paragraph("It’s easy to " + keyword("link") + " multiple machines or keys to your Charm account. Just run " + code(parentName+" link") + " on a machine connected to the account to want to link to start the process."), 20 | Example: indent.String(fmt.Sprintf("%s link\b%s link XXXXXX", parentName, parentName), 2), 21 | Args: cobra.RangeArgs(0, 1), 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | // Log to file if specified in the environment 24 | cfg := getCharmConfig() 25 | if cfg.Logfile != "" { 26 | f, err := tea.LogToFile(cfg.Logfile, "charm") 27 | if err != nil { 28 | return err 29 | } 30 | defer f.Close() //nolint:errcheck 31 | } 32 | 33 | var p *tea.Program 34 | switch len(args) { 35 | case 0: 36 | // Initialize a linking session 37 | p = linkgen.NewProgram(cfg, parentName) 38 | default: 39 | // Join in on a linking session 40 | p = link.NewProgram(cfg, args[0]) 41 | } 42 | if _, err := p.Run(); err != nil { 43 | return err 44 | } 45 | return nil 46 | }, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /cmd/migrate_account.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/log" 7 | 8 | "github.com/charmbracelet/charm/client" 9 | "github.com/charmbracelet/charm/proto" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // MigrateAccountCmd is a command to convert your legacy RSA SSH keys to the 14 | // new Ed25519 standard keys. 15 | var ( 16 | verbose bool 17 | linkError bool 18 | 19 | MigrateAccountCmd = &cobra.Command{ 20 | Use: "migrate-account", 21 | Hidden: true, 22 | Short: "", 23 | Long: "", 24 | Args: cobra.NoArgs, 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | fmt.Println("Migrating account...") 27 | rcfg, err := client.ConfigFromEnv() 28 | if err != nil { 29 | return err 30 | } 31 | rcfg.KeyType = "rsa" 32 | rsaClient, err := client.NewClient(rcfg) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | ecfg, err := client.ConfigFromEnv() 38 | if err != nil { 39 | return err 40 | } 41 | ecfg.KeyType = "ed25519" 42 | ed25519Client, err := client.NewClient(ecfg) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | lc := make(chan string) 48 | go func() { 49 | lh := &linkHandler{desc: "link-gen", linkChan: lc} 50 | _ = rsaClient.LinkGen(lh) 51 | }() 52 | tok := <-lc 53 | lh := &linkHandler{desc: "link-request", linkChan: lc} 54 | _ = ed25519Client.Link(lh, tok) 55 | if verbose { 56 | log.Info("link-gen sync encrypt keys") 57 | } 58 | err = rsaClient.SyncEncryptKeys() 59 | if err != nil { 60 | if verbose { 61 | log.Info("link-gen sync encrypt keys failed") 62 | } else { 63 | printError() 64 | } 65 | return err 66 | } 67 | if verbose { 68 | log.Info("link-request sync encrypt keys") 69 | } 70 | err = ed25519Client.SyncEncryptKeys() 71 | if err != nil { 72 | if verbose { 73 | log.Info("link-request sync encrypt keys failed") 74 | } else { 75 | printError() 76 | } 77 | return err 78 | } 79 | if !linkError { 80 | fmt.Println("Account migrated! You're good to go.") 81 | } else { 82 | printError() 83 | } 84 | return nil 85 | }, 86 | } 87 | ) 88 | 89 | type linkHandler struct { 90 | desc string 91 | linkChan chan string 92 | } 93 | 94 | func (lh *linkHandler) TokenCreated(l *proto.Link) { 95 | lh.printDebug("token created", l) 96 | lh.linkChan <- string(l.Token) 97 | lh.printDebug("token created sent to chan", l) 98 | } 99 | 100 | func (lh *linkHandler) TokenSent(l *proto.Link) { 101 | lh.printDebug("token sent", l) 102 | } 103 | 104 | func (lh *linkHandler) ValidToken(l *proto.Link) { 105 | lh.printDebug("valid token", l) 106 | } 107 | 108 | func (lh *linkHandler) InvalidToken(l *proto.Link) { 109 | lh.printDebug("invalid token", l) 110 | } 111 | 112 | func (lh *linkHandler) Request(l *proto.Link) bool { 113 | lh.printDebug("request", l) 114 | return true 115 | } 116 | 117 | func (lh *linkHandler) RequestDenied(l *proto.Link) { 118 | lh.printDebug("request denied", l) 119 | } 120 | 121 | func (lh *linkHandler) SameUser(l *proto.Link) { 122 | lh.printDebug("same user", l) 123 | } 124 | 125 | func (lh *linkHandler) Success(l *proto.Link) { 126 | lh.printDebug("success", l) 127 | } 128 | 129 | func (lh *linkHandler) Timeout(l *proto.Link) { 130 | lh.printDebug("timeout", l) 131 | } 132 | 133 | func (lh linkHandler) Error(l *proto.Link) { 134 | linkError = true 135 | lh.printDebug("error", l) 136 | if !verbose { 137 | printError() 138 | } 139 | } 140 | 141 | func (lh *linkHandler) printDebug(msg string, l *proto.Link) { 142 | if verbose { 143 | log.Info("%s %s:\t%v\n", lh.desc, msg, l) 144 | } 145 | } 146 | 147 | func printError() { 148 | fmt.Println("\nThere was an error migrating your account. Please re-run with the -v argument `charm migrate-account -v` and join our slack at https://charm.sh/slack to help debug the issue. Sorry about that, we'll try to figure it out!") 149 | } 150 | 151 | func init() { 152 | MigrateAccountCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "print debug output") 153 | } 154 | -------------------------------------------------------------------------------- /cmd/name.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/charm/client" 8 | charm "github.com/charmbracelet/charm/proto" 9 | "github.com/muesli/reflow/indent" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // NameCmd is the cobra.Command to print or set a username. 14 | var NameCmd = &cobra.Command{ 15 | Use: "name [username]", 16 | Short: "Username stuff", 17 | Long: paragraph("Print or set your " + keyword("username") + ". If the name is already taken, just run it again with a different, cooler name. Basic latin letters and numbers only, 50 characters max."), 18 | Args: cobra.RangeArgs(0, 1), 19 | Example: indent.String("charm name\ncharm name beatrix", 2), 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | cc := initCharmClient() 22 | switch len(args) { 23 | case 0: 24 | u, err := cc.Bio() 25 | if err != nil { 26 | return err 27 | } 28 | 29 | fmt.Println(u.Name) 30 | return nil 31 | default: 32 | n := args[0] 33 | if !client.ValidateName(n) { 34 | msg := fmt.Sprintf("%s is invalid.\n\nUsernames must be basic latin letters, numerals, and no more than 50 characters. And no emojis, kid.\n", code(n)) 35 | fmt.Println(paragraph(msg)) 36 | os.Exit(1) 37 | } 38 | u, err := cc.SetName(n) 39 | if err == charm.ErrNameTaken { 40 | paragraph(fmt.Sprintf("User name %s is already taken. Try a different, cooler name.\n", code(n))) 41 | os.Exit(1) 42 | } 43 | if err != nil { 44 | paragraph(fmt.Sprintf("Welp, there’s been an error. %s", subtle(err.Error()))) 45 | return err 46 | } 47 | 48 | paragraph(fmt.Sprintf("OK! Your new username is %s", code(u.Name))) 49 | return nil 50 | } 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /cmd/post_news.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/charmbracelet/charm/server" 9 | "github.com/charmbracelet/keygen" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var ( 14 | newsTagList string 15 | newsSubject string 16 | 17 | // PostNewsCmd is the cobra.Command to self-host the Charm Cloud. 18 | PostNewsCmd = &cobra.Command{ 19 | Use: "post-news", 20 | Hidden: true, 21 | Short: "Post news to the self-hosted Charm server.", 22 | Args: cobra.ExactArgs(1), 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | cfg := server.DefaultConfig() 25 | if serverDataDir != "" { 26 | cfg.DataDir = serverDataDir 27 | } 28 | sp := filepath.Join(cfg.DataDir, ".ssh") 29 | kp, err := keygen.New(filepath.Join(sp, "charm_server_ed25519"), keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite()) 30 | if err != nil { 31 | return err 32 | } 33 | cfg = cfg.WithKeys(kp.RawAuthorizedKey(), kp.RawPrivateKey()) 34 | s, err := server.NewServer(cfg) 35 | if err != nil { 36 | return err 37 | } 38 | if newsSubject == "" { 39 | newsSubject = args[0] 40 | } 41 | ts := strings.Split(newsTagList, ",") 42 | d, err := os.ReadFile(args[0]) 43 | if err != nil { 44 | return err 45 | } 46 | err = s.Config.DB.PostNews(newsSubject, string(d), ts) 47 | if err != nil { 48 | return err 49 | } 50 | return nil 51 | }, 52 | } 53 | ) 54 | 55 | func init() { 56 | PostNewsCmd.Flags().StringVarP(&newsSubject, "subject", "s", "", "Subject for news post") 57 | PostNewsCmd.Flags().StringVarP(&newsTagList, "tags", "t", "server", "Tags for news post, comma separated") 58 | PostNewsCmd.Flags().StringVarP(&serverDataDir, "data-dir", "", "", "Directory to store SQLite db, SSH keys and file data") 59 | } 60 | -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "path/filepath" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/charmbracelet/log" 12 | 13 | "github.com/charmbracelet/charm/server" 14 | "github.com/charmbracelet/keygen" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | var ( 19 | serverHTTPPort int 20 | serverSSHPort int 21 | serverStatsPort int 22 | serverHealthPort int 23 | serverDataDir string 24 | 25 | // ServeCmd is the cobra.Command to self-host the Charm Cloud. 26 | ServeCmd = &cobra.Command{ 27 | Use: "serve", 28 | Aliases: []string{"server"}, 29 | Hidden: false, 30 | Short: "Start a self-hosted Charm Cloud server.", 31 | Long: paragraph("Start the SSH and HTTP servers needed to power a SQLite-backed Charm Cloud."), 32 | Args: cobra.NoArgs, 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | cfg := server.DefaultConfig() 35 | if serverHTTPPort != 0 { 36 | cfg.HTTPPort = serverHTTPPort 37 | } 38 | if serverSSHPort != 0 { 39 | cfg.SSHPort = serverSSHPort 40 | } 41 | if serverStatsPort != 0 { 42 | cfg.StatsPort = serverStatsPort 43 | } 44 | if serverHealthPort != 0 { 45 | cfg.HealthPort = serverHealthPort 46 | } 47 | if serverDataDir != "" { 48 | cfg.DataDir = serverDataDir 49 | } 50 | sp := filepath.Join(cfg.DataDir, ".ssh") 51 | kp, err := keygen.New(filepath.Join(sp, "charm_server_ed25519"), keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite()) 52 | if err != nil { 53 | return err 54 | } 55 | cfg = cfg.WithKeys(kp.RawAuthorizedKey(), kp.RawPrivateKey()) 56 | s, err := server.NewServer(cfg) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | done := make(chan os.Signal, 1) 62 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 63 | go func() { 64 | if err := s.Start(); err != nil { 65 | log.Fatal("error starting server", "err", err) 66 | } 67 | }() 68 | 69 | <-done 70 | 71 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 72 | defer func() { cancel() }() 73 | 74 | return s.Shutdown(ctx) 75 | }, 76 | } 77 | ) 78 | 79 | func init() { 80 | ServeCmd.AddCommand( 81 | ServeMigrationCmd, 82 | ) 83 | ServeCmd.Flags().IntVar(&serverHTTPPort, "http-port", 0, "HTTP port to listen on") 84 | ServeCmd.Flags().IntVar(&serverSSHPort, "ssh-port", 0, "SSH port to listen on") 85 | ServeCmd.Flags().IntVar(&serverStatsPort, "stats-port", 0, "Stats port to listen on") 86 | ServeCmd.Flags().IntVar(&serverHealthPort, "health-port", 0, "Health port to listen on") 87 | ServeCmd.Flags().StringVar(&serverDataDir, "data-dir", "", "Directory to store SQLite db, SSH keys and file data") 88 | } 89 | -------------------------------------------------------------------------------- /cmd/serve_migrate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/charmbracelet/log" 10 | 11 | "github.com/charmbracelet/charm/server" 12 | "github.com/charmbracelet/charm/server/db/sqlite" 13 | "github.com/charmbracelet/charm/server/db/sqlite/migration" 14 | "github.com/spf13/cobra" 15 | 16 | _ "modernc.org/sqlite" // sqlite driver 17 | ) 18 | 19 | // ServeMigrationCmd migrate server db. 20 | var ServeMigrationCmd = &cobra.Command{ 21 | Use: "migrate", 22 | Aliases: []string{"migration"}, 23 | Hidden: true, 24 | Short: "Run the server migration tool.", 25 | Long: paragraph("Run the server migration tool to migrate the database."), 26 | Args: cobra.NoArgs, 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | cfg := server.DefaultConfig() 29 | dp := filepath.Join(cfg.DataDir, "db", sqlite.DbName) 30 | _, err := os.Stat(dp) 31 | if err != nil { 32 | return fmt.Errorf("database does not exist: %s", err) 33 | } 34 | db := sqlite.NewDB(dp) 35 | for _, m := range []migration.Migration{ 36 | migration.Migration0001, 37 | } { 38 | log.Print("Running migration", "id", fmt.Sprintf("%04d", m.ID), "name", m.Name) 39 | err = db.WrapTransaction(func(tx *sql.Tx) error { 40 | _, err := tx.Exec(m.SQL) 41 | if err != nil { 42 | return err 43 | } 44 | return nil 45 | }) 46 | if err != nil { 47 | break 48 | } 49 | } 50 | if err != nil { 51 | return err 52 | } 53 | return nil 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /cmd/where.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // WhereCmd is a command to find the absolute path to your charm data folder. 10 | var WhereCmd = &cobra.Command{ 11 | Use: "where", 12 | Short: "Find where your cloud.charm.sh folder resides on your machine", 13 | Long: paragraph("Find the absolute path to your charm keys, databases, etc."), 14 | RunE: func(cmd *cobra.Command, args []string) error { 15 | cc := initCharmClient() 16 | path, err := cc.DataPath() 17 | if err != nil { 18 | return err 19 | } 20 | fmt.Fprintln(cmd.OutOrStdout(), path) 21 | return nil 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /crypt/README.md: -------------------------------------------------------------------------------- 1 | # Charm Crypt 2 | 3 | We take privacy seriously. All data stored in the Charm Cloud is encrypted, 4 | decryptable only with your Charm account. That means even we don't have the 5 | ability to decrypt or view your data. 6 | 7 | ## Usage 8 | 9 | ```bash 10 | # encrypt secrets 11 | charm crypt encrypt < secrets.md > encryptedsecrets.md 12 | 13 | # decrypt secrets 14 | charm crypt decrypt < encryptedsecrets.md 15 | 16 | # am lost, need help 17 | charm crypt -h 18 | ``` 19 | 20 | ## How it works 21 | 22 | Encryption works by issuing symmetric keys (basically a generated password) and 23 | encrypting it with the local SSH public key generated by `charm`. That 24 | encrypted key is then sent up to our server. We can’t read it since we don’t 25 | have your private key. When you want to decrypt something or view your stash, 26 | that key is downloaded from our server and decrypted locally using the SSH 27 | private key. When you link accounts, the symmetric key is encrypted for each 28 | new public key. This happens on your machine and not our server, so we never 29 | see any unencrypted data from you. 30 | -------------------------------------------------------------------------------- /crypt/crypt.go: -------------------------------------------------------------------------------- 1 | // Package crypt provides encryption writer/readers. 2 | package crypt 3 | 4 | import ( 5 | "encoding/hex" 6 | "fmt" 7 | "io" 8 | 9 | "github.com/charmbracelet/charm/client" 10 | charm "github.com/charmbracelet/charm/proto" 11 | "github.com/jacobsa/crypto/siv" 12 | "github.com/muesli/sasquatch" 13 | ) 14 | 15 | // ErrIncorrectEncryptKeys is returned when the encrypt keys are missing or 16 | // incorrect for the encrypted data. 17 | var ErrIncorrectEncryptKeys = fmt.Errorf("incorrect or missing encrypt keys") 18 | 19 | // Crypt manages the account and encryption keys used for encrypting and 20 | // decrypting. 21 | type Crypt struct { 22 | keys []*charm.EncryptKey 23 | } 24 | 25 | // EncryptedWriter is an io.WriteCloser. All data written to this writer is 26 | // encrypted before being written to the underlying io.Writer. 27 | type EncryptedWriter struct { 28 | w io.WriteCloser 29 | } 30 | 31 | // DecryptedReader is an io.Reader that decrypts data from an encrypted 32 | // underlying io.Reader. 33 | type DecryptedReader struct { 34 | r io.Reader 35 | } 36 | 37 | // NewCrypt authenticates a user to the Charm Cloud and returns a Crypt struct 38 | // ready for encrypting and decrypting. 39 | func NewCrypt() (*Crypt, error) { 40 | cc, err := client.NewClientWithDefaults() 41 | if err != nil { 42 | return nil, err 43 | } 44 | eks, err := cc.EncryptKeys() 45 | if err != nil { 46 | return nil, err 47 | } 48 | if len(eks) == 0 { 49 | return nil, ErrIncorrectEncryptKeys 50 | } 51 | return &Crypt{keys: eks}, nil 52 | } 53 | 54 | // NewDecryptedReader creates a new Reader that will read from and decrypt the 55 | // passed in io.Reader of encrypted data. 56 | func (cr *Crypt) NewDecryptedReader(r io.Reader) (*DecryptedReader, error) { 57 | var sdr io.Reader 58 | dr := &DecryptedReader{} 59 | for _, k := range cr.keys { 60 | id, err := sasquatch.NewScryptIdentity(k.Key) 61 | if err != nil { 62 | return nil, err 63 | } 64 | sdr, err = sasquatch.Decrypt(r, id) 65 | if err == nil { 66 | break 67 | } 68 | } 69 | if sdr == nil { 70 | return nil, ErrIncorrectEncryptKeys 71 | } 72 | dr.r = sdr 73 | return dr, nil 74 | } 75 | 76 | // NewEncryptedWriter creates a new Writer that encrypts all data and writes 77 | // the encrypted data to the supplied io.Writer. 78 | func (cr *Crypt) NewEncryptedWriter(w io.Writer) (*EncryptedWriter, error) { 79 | ew := &EncryptedWriter{} 80 | rec, err := sasquatch.NewScryptRecipient(cr.keys[0].Key) 81 | if err != nil { 82 | return ew, err 83 | } 84 | sew, err := sasquatch.Encrypt(w, rec) 85 | if err != nil { 86 | return ew, err 87 | } 88 | ew.w = sew 89 | return ew, nil 90 | } 91 | 92 | // Keys returns the EncryptKeys this Crypt is using. 93 | func (cr *Crypt) Keys() []*charm.EncryptKey { 94 | return cr.keys 95 | } 96 | 97 | // EncryptLookupField will deterministically encrypt a string and the same 98 | // encrypted value every time this string is encrypted with the same 99 | // EncryptKey. This is useful if you need to look up an encrypted value without 100 | // knowing the plaintext on the storage side. For writing encrypted data, use 101 | // EncryptedWriter which is non-deterministic. 102 | func (cr *Crypt) EncryptLookupField(field string) (string, error) { 103 | if field == "" { 104 | return "", nil 105 | } 106 | ct, err := siv.Encrypt(nil, []byte(cr.keys[0].Key[:32]), []byte(field), nil) 107 | if err != nil { 108 | return "", err 109 | } 110 | return hex.EncodeToString(ct), nil 111 | } 112 | 113 | // DecryptLookupField decrypts a string encrypted with EncryptLookupField. 114 | func (cr *Crypt) DecryptLookupField(field string) (string, error) { 115 | if field == "" { 116 | return "", nil 117 | } 118 | ct, err := hex.DecodeString(field) 119 | if err != nil { 120 | return "", err 121 | } 122 | var pt []byte 123 | for _, k := range cr.keys { 124 | pt, err = siv.Decrypt([]byte(k.Key[:32]), ct, nil) 125 | if err == nil { 126 | break 127 | } 128 | } 129 | if len(pt) == 0 { 130 | return "", ErrIncorrectEncryptKeys 131 | } 132 | return string(pt), nil 133 | } 134 | 135 | // Read decrypts and reads data from the underlying io.Reader. 136 | func (dr *DecryptedReader) Read(p []byte) (int, error) { 137 | return dr.r.Read(p) 138 | } 139 | 140 | // Write encrypts data and writes it to the underlying io.WriteCloser. 141 | func (ew *EncryptedWriter) Write(p []byte) (int, error) { 142 | return ew.w.Write(p) 143 | } 144 | 145 | // Close closes the underlying io.WriteCloser. 146 | func (ew *EncryptedWriter) Close() error { 147 | return ew.w.Close() 148 | } 149 | -------------------------------------------------------------------------------- /docker.md: -------------------------------------------------------------------------------- 1 | # Running Charm with Docker 2 | 3 | The official Charm images are available at [charmcli/charm](https://hub.docker.com/r/charmcli/charm). Development and nightly builds are available at [ghcr.io/charmbracelet/charm](https://ghcr.io/charmbracelet/charm). 4 | 5 | ```sh 6 | docker pull charmcli/charm:latest 7 | ``` 8 | 9 | Here’s how you might run `charm` as a container. Keep in mind that 10 | the database is stored in the `/data` directory, so you’ll likely want 11 | to mount that directory as a volume in order keep your your data backed up. 12 | 13 | ```sh 14 | docker run \ 15 | --name=charm \ 16 | -v /path/to/data:/data \ 17 | -p 35353:35353 \ 18 | -p 35354:35354 \ 19 | -p 35355:35355 \ 20 | -p 35356:35356 \ 21 | --restart unless-stopped \ 22 | charmcli/charm:latest 23 | ``` 24 | 25 | or by using `docker-compose`: 26 | 27 | ```yaml 28 | version: "3.1" 29 | services: 30 | charm: 31 | image: charmcli/charm:latest 32 | container_name: charm 33 | volumes: 34 | - /path/to/data:/data 35 | ports: 36 | - 35353:35353 37 | - 35354:35354 38 | - 35355:35355 39 | - 35356:35356 40 | restart: unless-stopped 41 | ``` 42 | 43 | To set up TLS under Docker, consider using a reverse proxy such as 44 | [traefik](https://doc.traefik.io/traefik/https/overview/) or a web server with 45 | automatic HTTPS like [caddy](https://caddyserver.com/docs/automatic-https). If 46 | you're using a reverse proxy, you will need to set `CHARM_SERVER_HOST` to your 47 | public host, and `CHARM_SERVER_PUBLIC_URL` to the full public URL of your 48 | reverse proxy i.e. `CHARM_SERVER_PUBLIC_URL=https://cloud.charm.sh:35354`. 49 | 50 | *** 51 | 52 | Part of [Charm](https://charm.sh). 53 | 54 | the Charm logo 55 | 56 | Charm热爱开源 • Charm loves open source 57 | 58 | 59 | [releases]: https://github.com/charmbracelet/charm/releases 60 | [docs]: https://pkg.go.dev/github.com/charmbracelet/charm?tab=doc 61 | [kv]: https://github.com/charmbracelet/charm/tree/main/kv 62 | [fs]: https://github.com/charmbracelet/charm/tree/main/fs 63 | [crypt]: https://github.com/charmbracelet/charm/tree/main/crypt 64 | [glow]: https://github.com/charmbracelet/glow 65 | [skate]: https://github.com/charmbracelet/skate 66 | [badger]: https://github.com/dgraph-io/badger 67 | -------------------------------------------------------------------------------- /docs/backup-account.md: -------------------------------------------------------------------------------- 1 | # Backing up your account 2 | 3 | When you first run `charm`, it creates a new ED25519 key pair for you. That 4 | private key is the __key__ to your data. 5 | 6 | To back it up, you can use the `backup-keys` command, as such: 7 | 8 | ```shell 9 | charm backup-keys 10 | ``` 11 | 12 | It'll create a `charm-keys-backup.tar` file in the current folder. You can 13 | override the path by passing a `-o` flag, as such: 14 | 15 | ```shell 16 | charm backup-keys -o ~/charm.tar 17 | ``` 18 | 19 | You may also print the private key to STDOUT in order to pipe it into other 20 | command, such as [`melt`](https://github.com/charmbracelet/melt). Example 21 | usage: 22 | 23 | ```shell 24 | charm backup-keys -o - | melt 25 | ``` 26 | 27 | Also worth reading [./docs/restore-account.md](./restore-account.md). 28 | -------------------------------------------------------------------------------- /docs/restore-account.md: -------------------------------------------------------------------------------- 1 | # Restoring from a backup 2 | 3 | To restore your account, you can use the `import-keys` command: 4 | 5 | ```shell 6 | charm import-keys charm-keys-backup.tar 7 | ``` 8 | 9 | You can also import a private key from STDIN from another tool, such as 10 | [melt](https://github.com/charmbracelet/melt): 11 | 12 | ```shell 13 | cat seed.txt | melt restore - | charm import-keys 14 | ``` 15 | 16 | Also worth reading [how to backup your account](./backup-account.md). 17 | -------------------------------------------------------------------------------- /docs/self-hosting.md: -------------------------------------------------------------------------------- 1 | # Self-Hosting Charm 2 | 3 | Charm libraries point at our Charmbracelet, Inc. servers by default (that’s 4 | *cloud.charm.sh*), however it's very easy for users to host their own Charm 5 | instances. The charm binary is a single, statically-linked executable capable 6 | of serving an entire Charm instance. 7 | 8 | ## Ze Server 9 | 10 | To start your charm server, run `charm serve` in a dedicated terminal window or 11 | in a [Docker container](https://github.com/charmbracelet/charm/blob/main/docker.md). 12 | Then, change the default host by adding `CHARM_HOST=localhost` or 13 | `CHARM_HOST=burrito.example.com` to your PATH. 14 | 15 | ## Ze Client 16 | 17 | If you're using a reverse proxy with your self-hosted Charm server, you'll want 18 | to change a few environment variables. Namely, 19 | 20 | * `CHARM_HOST`: This should match the public URL to your Charm server. 21 | * `CHARM_HTTP_PORT`: This should match the port your reverse proxy accepts for HTTP connections. 22 | * `CHARM_SERVER_PUBLIC_URL`: This is the public URL set on your Charm server. 23 | 24 | By default, the `CHARM_HTTP_PORT` value is set to `35354`. If you're using a 25 | default HTTP reverse proxy, you'll need to change the reverse proxy to accept 26 | port `35354` for HTTP connections or change the `CHARM_HTTP_PORT` to `443` on 27 | the client side. 28 | 29 | ## Self-Hosting With TLS 30 | 31 | ### About our Setup 32 | 33 | We're hosting our infrastructure on AWS. The Charm instance uses 2 load 34 | balancers, one is layer 4 (NLB) for handling SSH requests, and the other is 35 | layer 7 (ALB) for handling HTTPS SSL/TLS requests. TLS gets terminated at the 36 | load balancer level, then the ALB communicates with the Charm instance in plain 37 | HTTP no-TLS. 38 | 39 | The NLB handles incoming traffic using a TCP listener on port `35353` and 40 | forwards that to the Charm instance port `35353`. The ALB handles incoming 41 | traffic using an HTTPS listener on port `35354`, terminates TLS, and forwards 42 | plain HTTP to the Charm instance on port `35354` 43 | 44 | ### Using Your Own TLS Certificate 45 | 46 | If you want to use your own TLS certificate, you could specify 47 | `CHARM_SERVER_USE_TLS`, `CHARM_SERVER_TLS_KEY_FILE`, and 48 | `CHARM_SERVER_TLS_CERT_FILE`. In this case, the Charm HTTP server will handle 49 | TLS terminations. 50 | 51 | ### Configuring Your VPS 52 | 53 | In nginx, you could set up Let's Encrypt, SSL termination, and HTTPS/SSL on 54 | port `35354`, then use proxy_pass to reverse proxy the requests to your Charm 55 | instance. For SSH port `35353`, you'd just need to make sure that this port 56 | accepts incoming traffic on the VPS. 57 | 58 | Helpful resources: 59 | [1] https://docs.nginx.com/nginx/admin-guide/security-controls/terminating-ssl-http/ 60 | [2] https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/ 61 | [3] https://upcloud.com/community/tutorials/install-lets-encrypt-nginx/ 62 | 63 | ## Storage Restrictions 64 | 65 | The self-hosting max data is disabled by default. You can change that using 66 | `CHARM_SERVER_USER_MAX_STORAGE` 67 | -------------------------------------------------------------------------------- /fs/README.md: -------------------------------------------------------------------------------- 1 | # Charm FS 2 | 3 | ## Example 4 | 5 | ```go 6 | package main 7 | 8 | import ( 9 | "bytes" 10 | "fmt" 11 | "io" 12 | "io/fs" 13 | "os" 14 | 15 | charmfs "github.com/charmbracelet/charm/fs" 16 | ) 17 | 18 | func main() { 19 | // Open the file system 20 | cfs, err := charmfs.NewFS() 21 | if err != nil { 22 | panic(err) 23 | } 24 | // Write a file 25 | data := []byte("some data") 26 | err = os.WriteFile("/tmp/data", data, 0644) 27 | if err != nil { 28 | panic(err) 29 | } 30 | file, err := os.Open("/tmp/data") 31 | if err != nil { 32 | panic(err) 33 | } 34 | err = cfs.WriteFile("/our/test/data", file) 35 | if err != nil { 36 | panic(err) 37 | } 38 | // Get a file 39 | f, err := cfs.Open("/our/test/data") 40 | if err != nil { 41 | panic(err) 42 | } 43 | buf = bytes.NewBuffer(nil) 44 | _, err = io.Copy(buf, f) 45 | if err != nil { 46 | panic(err) 47 | } 48 | fmt.Println(string(buf.Bytes())) 49 | 50 | // Or use fs.ReadFileFS 51 | bs, err := cfs.ReadFile("/our/test/data") 52 | if err != nil { 53 | panic(err) 54 | } 55 | fmt.Println(string(bs)) 56 | 57 | // Since we're using fs.FS interfaces we can also do things like walk a tree 58 | err = fs.WalkDir(cfs, "/", func(path string, d fs.DirEntry, err error) error { 59 | fmt.Println(path) 60 | return nil 61 | }) 62 | if err != nil { 63 | panic(err) 64 | } 65 | } 66 | ``` 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charmbracelet/charm 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/auth0/go-jwt-middleware/v2 v2.2.1 7 | github.com/caarlos0/env/v6 v6.10.1 8 | github.com/calmh/randomart v1.1.0 9 | github.com/charmbracelet/bubbles v0.20.0 10 | github.com/charmbracelet/bubbletea v1.3.3 11 | github.com/charmbracelet/keygen v0.5.1 12 | github.com/charmbracelet/lipgloss v1.0.0 13 | github.com/charmbracelet/log v0.2.2 14 | github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 15 | github.com/charmbracelet/wish v1.1.1 16 | github.com/dgraph-io/badger/v3 v3.2103.2 17 | github.com/golang-jwt/jwt/v4 v4.5.1 18 | github.com/google/uuid v1.3.0 19 | github.com/jacobsa/crypto v0.0.0-20190317225127-9f44e2d11115 20 | github.com/mattn/go-isatty v0.0.20 21 | github.com/meowgorithm/babylogger v1.2.1 22 | github.com/mitchellh/go-homedir v1.1.0 23 | github.com/muesli/go-app-paths v0.2.2 24 | github.com/muesli/mango-cobra v1.2.0 25 | github.com/muesli/reflow v0.3.0 26 | github.com/muesli/roff v0.1.0 27 | github.com/muesli/sasquatch v0.0.0-20200811221207-66979d92330a 28 | github.com/muesli/toktok v0.1.0 29 | github.com/prometheus/client_golang v1.20.5 30 | github.com/spf13/cobra v1.9.1 31 | goji.io v2.0.2+incompatible 32 | golang.org/x/crypto v0.31.0 33 | golang.org/x/sync v0.11.0 34 | gopkg.in/go-jose/go-jose.v2 v2.6.2 35 | modernc.org/sqlite v1.29.2 36 | ) 37 | 38 | require ( 39 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 40 | github.com/atotto/clipboard v0.1.4 // indirect 41 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 42 | github.com/beorn7/perks v1.0.1 // indirect 43 | github.com/cespare/xxhash v1.1.0 // indirect 44 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 45 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 46 | github.com/charmbracelet/x/term v0.2.1 // indirect 47 | github.com/dgraph-io/ristretto v0.1.0 // indirect 48 | github.com/dustin/go-humanize v1.0.1 // indirect 49 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 50 | github.com/go-logfmt/logfmt v0.6.0 // indirect 51 | github.com/gogo/protobuf v1.3.2 // indirect 52 | github.com/golang/glog v1.2.4 // indirect 53 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect 54 | github.com/golang/protobuf v1.5.3 // indirect 55 | github.com/golang/snappy v0.0.3 // indirect 56 | github.com/google/flatbuffers v1.12.1 // indirect 57 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 58 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 59 | github.com/jacobsa/oglematchers v0.0.0-20150720000706-141901ea67cd // indirect 60 | github.com/jacobsa/oglemock v0.0.0-20150831005832-e94d794d06ff // indirect 61 | github.com/jacobsa/ogletest v0.0.0-20170503003838-80d50a735a11 // indirect 62 | github.com/jacobsa/reqtrace v0.0.0-20150505043853-245c9e0234cb // indirect 63 | github.com/klauspost/compress v1.17.9 // indirect 64 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 65 | github.com/mattn/go-localereader v0.0.1 // indirect 66 | github.com/mattn/go-runewidth v0.0.16 // indirect 67 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 68 | github.com/muesli/cancelreader v0.2.2 // indirect 69 | github.com/muesli/mango v0.1.0 // indirect 70 | github.com/muesli/mango-pflag v0.1.0 // indirect 71 | github.com/muesli/termenv v0.15.2 // indirect 72 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 73 | github.com/ncruces/go-strftime v0.1.9 // indirect 74 | github.com/pkg/errors v0.9.1 // indirect 75 | github.com/prometheus/client_model v0.6.1 // indirect 76 | github.com/prometheus/common v0.55.0 // indirect 77 | github.com/prometheus/procfs v0.15.1 // indirect 78 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 79 | github.com/rivo/uniseg v0.4.7 // indirect 80 | github.com/spf13/pflag v1.0.6 // indirect 81 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 82 | go.opencensus.io v0.22.5 // indirect 83 | golang.org/x/net v0.33.0 // indirect 84 | golang.org/x/sys v0.30.0 // indirect 85 | golang.org/x/term v0.27.0 // indirect 86 | golang.org/x/text v0.21.0 // indirect 87 | google.golang.org/protobuf v1.34.2 // indirect 88 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect 89 | modernc.org/libc v1.41.0 // indirect 90 | modernc.org/mathutil v1.6.0 // indirect 91 | modernc.org/memory v1.7.2 // indirect 92 | modernc.org/strutil v1.2.0 // indirect 93 | modernc.org/token v1.1.0 // indirect 94 | ) 95 | -------------------------------------------------------------------------------- /kv/README.md: -------------------------------------------------------------------------------- 1 | # Charm KV 2 | 3 | ## Example 4 | 5 | ```go 6 | package main 7 | 8 | import ( 9 | "fmt" 10 | 11 | "github.com/charmbracelet/charm/kv" 12 | "github.com/dgraph-io/badger/v3" 13 | ) 14 | 15 | func main() { 16 | // Open a kv store with the name "charm.sh.test.db" and local path ./db 17 | db, err := kv.OpenWithDefaults("charm.sh.test.db") 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | // Get the latest updates from the Charm Cloud 23 | db.Sync() 24 | 25 | // Quickly set a value 26 | err = db.Set([]byte("dog"), []byte("food")) 27 | if err != nil { 28 | panic(err) 29 | } 30 | 31 | // Quickly get a value 32 | v, err := db.Get([]byte("dog")) 33 | if err != nil { 34 | panic(err) 35 | } 36 | fmt.Printf("got value: %s\n", string(v)) 37 | 38 | // Go full-blown Badger and use transactions to list values and keys 39 | db.View(func(txn *badger.Txn) error { 40 | opts := badger.DefaultIteratorOptions 41 | opts.PrefetchSize = 10 42 | it := txn.NewIterator(opts) 43 | defer it.Close() //nolint:errcheck 44 | for it.Rewind(); it.Valid(); it.Next() { 45 | item := it.Item() 46 | k := item.Key() 47 | err := item.Value(func(v []byte) error { 48 | fmt.Printf("%s - %s\n", k, v) 49 | return nil 50 | }) 51 | if err != nil { 52 | panic(err) 53 | } 54 | } 55 | return nil 56 | }) 57 | } 58 | ``` 59 | 60 | ## Deleting a Database 61 | 62 | 1. Find the database in `charm fs ls /` 63 | 2. Delete the database with `charm fs rm db-name` 64 | 3. Locate the local copy of the database. To see where your charm-related data lives, run `charm` to start up with GUI, then select `Backup` 65 | 4. Run `rm ~/path/to/cloud.charm.sh/kv/db-name` to remove the local copy of your charm-kv database 66 | -------------------------------------------------------------------------------- /kv/client.go: -------------------------------------------------------------------------------- 1 | package kv 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/fs" 7 | "math" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/charmbracelet/charm/client" 13 | charm "github.com/charmbracelet/charm/proto" 14 | badger "github.com/dgraph-io/badger/v3" 15 | ) 16 | 17 | type kvFile struct { 18 | data *bytes.Buffer 19 | info *kvFileInfo 20 | } 21 | 22 | type kvFileInfo struct { 23 | name string 24 | size int64 25 | mode fs.FileMode 26 | modTime time.Time 27 | } 28 | 29 | func (f *kvFileInfo) Name() string { 30 | return f.name 31 | } 32 | 33 | func (f *kvFileInfo) Size() int64 { 34 | return f.size 35 | } 36 | 37 | func (f *kvFileInfo) Mode() fs.FileMode { 38 | return f.mode 39 | } 40 | 41 | func (f *kvFileInfo) ModTime() time.Time { 42 | return f.modTime 43 | } 44 | 45 | func (f *kvFileInfo) IsDir() bool { 46 | return f.mode&fs.ModeDir != 0 47 | } 48 | 49 | func (f *kvFileInfo) Sys() interface{} { 50 | return nil 51 | } 52 | 53 | func (f *kvFile) Stat() (fs.FileInfo, error) { 54 | if f.info == nil { 55 | return nil, fmt.Errorf("file info not set") 56 | } 57 | return f.info, nil 58 | } 59 | 60 | func (f *kvFile) Close() error { 61 | return nil 62 | } 63 | 64 | func (f *kvFile) Read(p []byte) (n int, err error) { 65 | return f.data.Read(p) 66 | } 67 | 68 | func (kv *KV) seqStorageKey(seq uint64) string { 69 | return strings.Join([]string{kv.name, fmt.Sprintf("%d", seq)}, "/") 70 | } 71 | 72 | func (kv *KV) backupSeq(from uint64, at uint64) error { 73 | buf := bytes.NewBuffer(nil) 74 | s := kv.DB.NewStreamAt(math.MaxUint64) 75 | size, err := s.Backup(buf, from) 76 | if err != nil { 77 | return err 78 | } 79 | name := kv.seqStorageKey(at) 80 | src := &kvFile{ 81 | data: buf, 82 | info: &kvFileInfo{ 83 | name: name, 84 | size: int64(size), 85 | mode: fs.FileMode(0o660), 86 | modTime: time.Now(), 87 | }, 88 | } 89 | return kv.fs.WriteFile(name, src) 90 | } 91 | 92 | func (kv *KV) restoreSeq(seq uint64) error { 93 | // there is never a zero seq 94 | if seq == 0 { 95 | return nil 96 | } 97 | r, err := kv.fs.Open(kv.seqStorageKey(seq)) 98 | if err != nil { 99 | return err 100 | } 101 | defer r.Close() // nolint:errcheck 102 | // nolint: godox 103 | // TODO DB.Load() should be called on a database that is not running any 104 | // other concurrent transactions while it is running. 105 | return kv.DB.Load(r, 1) 106 | } 107 | 108 | func (kv *KV) getSeq(name string) (uint64, error) { 109 | var sm *charm.SeqMsg 110 | name, err := kv.fs.EncryptPath(name) 111 | if err != nil { 112 | return 0, err 113 | } 114 | err = kv.cc.AuthedJSONRequest("GET", fmt.Sprintf("/v1/seq/%s", name), nil, &sm) 115 | if err != nil { 116 | return 0, err 117 | } 118 | return sm.Seq, nil 119 | } 120 | 121 | func (kv *KV) nextSeq(name string) (uint64, error) { 122 | var sm *charm.SeqMsg 123 | name, err := kv.fs.EncryptPath(name) 124 | if err != nil { 125 | return 0, err 126 | } 127 | err = kv.cc.AuthedJSONRequest("POST", fmt.Sprintf("/v1/seq/%s", name), nil, &sm) 128 | if err != nil { 129 | return 0, err 130 | } 131 | return sm.Seq, nil 132 | } 133 | 134 | func (kv *KV) syncFrom(mv uint64) error { 135 | seqDir, err := kv.fs.ReadDir(kv.name) 136 | if err != nil { 137 | return err 138 | } 139 | for _, de := range seqDir { 140 | ii, err := strconv.Atoi(de.Name()) 141 | if err != nil { 142 | return err 143 | } 144 | i := uint64(ii) 145 | if i > mv { 146 | err = kv.restoreSeq(i) 147 | if err != nil { 148 | return err 149 | } 150 | } 151 | } 152 | return nil 153 | } 154 | 155 | func encryptKeyToBadgerKey(k *charm.EncryptKey) ([]byte, error) { 156 | ek := []byte(k.Key) 157 | if len(ek) < 32 { 158 | return nil, fmt.Errorf("encryption key is too short") 159 | } 160 | return ek[0:32], nil 161 | } 162 | 163 | func openDB(cc *client.Client, opt badger.Options) (*badger.DB, error) { 164 | var db *badger.DB 165 | eks, err := cc.EncryptKeys() 166 | if err != nil { 167 | return nil, err 168 | } 169 | for _, k := range eks { 170 | ek, err := encryptKeyToBadgerKey(k) 171 | if err == nil { 172 | opt, err = OptionsWithEncryption(opt, ek, 32768) 173 | if err != nil { 174 | continue 175 | } 176 | db, err = badger.OpenManaged(opt) 177 | if err == nil { 178 | break 179 | } 180 | if err != nil { 181 | return nil, err 182 | } 183 | } 184 | } 185 | if db == nil { 186 | return nil, fmt.Errorf("could not open BadgerDB, bad encrypt keys") 187 | } 188 | return db, nil 189 | } 190 | -------------------------------------------------------------------------------- /kv/kv.go: -------------------------------------------------------------------------------- 1 | // Package kv provides a Charm Cloud backed BadgerDB. 2 | package kv 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "math" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/charmbracelet/log" 12 | 13 | "github.com/charmbracelet/charm/client" 14 | "github.com/charmbracelet/charm/fs" 15 | badger "github.com/dgraph-io/badger/v3" 16 | ) 17 | 18 | // KV provides a Charm Cloud backed BadgerDB key-value store. 19 | // 20 | // KV supports regular Badger transactions, and backs up the data to the Charm 21 | // Cloud. It will allow for syncing across machines linked with a Charm 22 | // account. All data is encrypted by Badger on the local disk using a Charm 23 | // user's encryption keys. Diffs are also encrypted locally before being synced 24 | // to the Charm Cloud. 25 | type KV struct { 26 | DB *badger.DB 27 | name string 28 | cc *client.Client 29 | fs *fs.FS 30 | } 31 | 32 | // Open a Charm Cloud managed Badger DB instance with badger.Options and 33 | // *client.Client. 34 | func Open(cc *client.Client, name string, opt badger.Options) (*KV, error) { 35 | db, err := openDB(cc, opt) 36 | if err != nil { 37 | return nil, err 38 | } 39 | fs, err := fs.NewFSWithClient(cc) 40 | if err != nil { 41 | return nil, err 42 | } 43 | return &KV{DB: db, name: name, cc: cc, fs: fs}, nil 44 | } 45 | 46 | // OpenWithDefaults opens a Charm Cloud managed Badger DB instance with the 47 | // default settings pulled from environment variables. 48 | func OpenWithDefaults(name string) (*KV, error) { 49 | cc, err := client.NewClientWithDefaults() 50 | if err != nil { 51 | return nil, err 52 | } 53 | dd, err := cc.DataPath() 54 | if err != nil { 55 | return nil, err 56 | } 57 | pn := filepath.Join(dd, "/kv/", name) 58 | opts := badger.DefaultOptions(pn).WithLoggingLevel(badger.ERROR) 59 | 60 | // By default we have no logger as it will interfere with Bubble Tea 61 | // rendering. Use Open with custom options to specify one. 62 | opts.Logger = nil 63 | 64 | // We default to a 10MB vlog max size (which BadgerDB turns into 20MB vlog 65 | // files). The Badger default results in 2GB vlog files, which is quite 66 | // large. This will limit the values to 10MB maximum size. If you need more, 67 | // please use Open with custom options. 68 | opts = opts.WithValueLogFileSize(10000000) 69 | return Open(cc, name, opts) 70 | } 71 | 72 | // OptionsWithEncryption returns badger.Options with all required encryption 73 | // settings enabled for a given encryption key. 74 | func OptionsWithEncryption(opt badger.Options, encKey []byte, cacheSize int64) (badger.Options, error) { 75 | if cacheSize <= 0 { 76 | return opt, fmt.Errorf("you must set an index cache size to use encrypted workloads in Badger v3") 77 | } 78 | return opt.WithEncryptionKey(encKey).WithIndexCacheSize(cacheSize), nil 79 | } 80 | 81 | // NewTransaction creates a new *badger.Txn with a Charm Cloud managed 82 | // timestamp. 83 | func (kv *KV) NewTransaction(update bool) (*badger.Txn, error) { 84 | var ts uint64 85 | var err error 86 | if update { 87 | ts, err = kv.getSeq(kv.name) 88 | if err != nil { 89 | return nil, err 90 | } 91 | } else { 92 | ts = math.MaxUint64 93 | } 94 | return kv.DB.NewTransactionAt(ts, update), nil 95 | } 96 | 97 | // NewStream returns a new *badger.Stream from the underlying Badger DB. 98 | func (kv *KV) NewStream() *badger.Stream { 99 | return kv.DB.NewStreamAt(math.MaxUint64) 100 | } 101 | 102 | // View wraps the View() method for the underlying Badger DB. 103 | func (kv *KV) View(fn func(txn *badger.Txn) error) error { 104 | return kv.DB.View(fn) 105 | } 106 | 107 | // Sync synchronizes the local Badger DB with any updates from the Charm Cloud. 108 | func (kv *KV) Sync() error { 109 | return kv.syncFrom(kv.DB.MaxVersion()) 110 | } 111 | 112 | // Commit commits a *badger.Txn and syncs the diff to the Charm Cloud. 113 | func (kv *KV) Commit(txn *badger.Txn, callback func(error)) error { 114 | mv := kv.DB.MaxVersion() 115 | err := kv.syncFrom(mv) 116 | if err != nil { 117 | return err 118 | } 119 | seq, err := kv.nextSeq(kv.name) 120 | if err != nil { 121 | return err 122 | } 123 | err = txn.CommitAt(seq, callback) 124 | if err != nil { 125 | return err 126 | } 127 | return kv.backupSeq(mv, seq) 128 | } 129 | 130 | // Close closes the underlying Badger DB. 131 | func (kv *KV) Close() error { 132 | return kv.DB.Close() 133 | } 134 | 135 | // Set is a convenience method for setting a key and value. It creates and 136 | // commits a new transaction for the update. 137 | func (kv *KV) Set(key []byte, value []byte) error { 138 | txn, err := kv.NewTransaction(true) 139 | if err != nil { 140 | return err 141 | } 142 | err = txn.Set(key, value) 143 | if err != nil { 144 | return err 145 | } 146 | return kv.Commit(txn, func(err error) { 147 | if err != nil { 148 | log.Error("Badger commit error", "err", err) 149 | } 150 | }) 151 | } 152 | 153 | // SetReader is a convenience method to set the value for a key to the data 154 | // read from the provided io.Reader. 155 | func (kv *KV) SetReader(key []byte, value io.Reader) error { 156 | v, err := io.ReadAll(value) 157 | if err != nil { 158 | return err 159 | } 160 | return kv.Set(key, v) 161 | } 162 | 163 | // Get is a convenience method for getting a value from the key value store. 164 | func (kv *KV) Get(key []byte) ([]byte, error) { 165 | var v []byte 166 | err := kv.View(func(txn *badger.Txn) error { 167 | item, err := txn.Get(key) 168 | if err != nil { 169 | return err 170 | } 171 | v, err = item.ValueCopy(nil) 172 | return err 173 | }) 174 | if err != nil { 175 | return v, err 176 | } 177 | return v, nil 178 | } 179 | 180 | // Delete is a convenience method for deleting a value from the key value store. 181 | func (kv *KV) Delete(key []byte) error { 182 | txn, err := kv.NewTransaction(true) 183 | if err != nil { 184 | return err 185 | } 186 | err = txn.Delete(key) 187 | if err != nil { 188 | return err 189 | } 190 | return kv.Commit(txn, func(err error) { 191 | if err != nil { 192 | log.Error("Badger commit error", "err", err) 193 | } 194 | }) 195 | } 196 | 197 | // Keys returns a list of all keys for this key value store. 198 | func (kv *KV) Keys() ([][]byte, error) { 199 | var ks [][]byte 200 | err := kv.View(func(txn *badger.Txn) error { 201 | opts := badger.DefaultIteratorOptions 202 | opts.PrefetchValues = false 203 | it := txn.NewIterator(opts) 204 | defer it.Close() //nolint:errcheck 205 | for it.Rewind(); it.Valid(); it.Next() { 206 | ks = append(ks, it.Item().KeyCopy(nil)) 207 | } 208 | return nil 209 | }) 210 | if err != nil { 211 | return nil, err 212 | } 213 | return ks, nil 214 | } 215 | 216 | // Client returns the underlying *client.Client. 217 | func (kv *KV) Client() *client.Client { 218 | return kv.cc 219 | } 220 | 221 | // Reset deletes the local copy of the Badger DB and rebuilds with a fresh sync 222 | // from the Charm Cloud. 223 | func (kv *KV) Reset() error { 224 | opts := kv.DB.Opts() 225 | err := kv.DB.Close() 226 | if err != nil { 227 | return err 228 | } 229 | err = os.RemoveAll(opts.Dir) 230 | if err != nil { 231 | return err 232 | } 233 | if opts.ValueDir != opts.Dir { 234 | err = os.RemoveAll(opts.ValueDir) 235 | if err != nil { 236 | return err 237 | } 238 | } 239 | db, err := openDB(kv.cc, opts) 240 | if err != nil { 241 | return err 242 | } 243 | kv.DB = db 244 | return kv.Sync() 245 | } 246 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime/debug" 7 | 8 | "github.com/charmbracelet/log" 9 | 10 | "github.com/charmbracelet/charm/client" 11 | "github.com/charmbracelet/charm/cmd" 12 | "github.com/charmbracelet/charm/ui" 13 | "github.com/charmbracelet/charm/ui/common" 14 | mcobra "github.com/muesli/mango-cobra" 15 | "github.com/muesli/roff" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | var ( 20 | // Version is the version of the charm CLI. 21 | Version = "" 22 | // CommitSHA is the commit SHA of the charm CLI. 23 | CommitSHA = "" 24 | 25 | styles = common.DefaultStyles() 26 | 27 | rootCmd = &cobra.Command{ 28 | Use: "charm", 29 | Short: "Do Charm stuff", 30 | Long: styles.Paragraph.Render(fmt.Sprintf("Do %s stuff. Run without arguments for a TUI or use the sub-commands like a pro.", styles.Keyword.Render("Charm"))), 31 | DisableFlagsInUseLine: true, 32 | RunE: func(cmd *cobra.Command, args []string) error { 33 | if common.IsTTY() { 34 | cfg, err := client.ConfigFromEnv() 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | 39 | // Log to file, if set 40 | if cfg.Logfile != "" { 41 | f, err := os.OpenFile(cfg.Logfile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) 42 | if err != nil { 43 | return err 44 | } 45 | if cfg.Debug { 46 | log.SetLevel(log.DebugLevel) 47 | } 48 | log.SetOutput(f) 49 | log.SetPrefix("charm") 50 | 51 | defer f.Close() // nolint: errcheck 52 | } 53 | 54 | p := ui.NewProgram(cfg) 55 | if _, err := p.Run(); err != nil { 56 | return err 57 | } 58 | } 59 | 60 | return cmd.Help() 61 | }, 62 | } 63 | 64 | manCmd = &cobra.Command{ 65 | Use: "man", 66 | Short: "Generate man pages", 67 | Args: cobra.NoArgs, 68 | Hidden: true, 69 | RunE: func(cmd *cobra.Command, args []string) error { 70 | manPage, err := mcobra.NewManPage(1, rootCmd) //. 71 | if err != nil { 72 | return err 73 | } 74 | 75 | manPage = manPage.WithSection("Copyright", "(C) 2021-2022 Charmbracelet, Inc.\n"+ 76 | "Released under MIT license.") 77 | fmt.Println(manPage.Build(roff.NewDocument())) 78 | return nil 79 | }, 80 | } 81 | ) 82 | 83 | func init() { 84 | if len(CommitSHA) >= 7 { 85 | vt := rootCmd.VersionTemplate() 86 | rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") 87 | } 88 | if Version == "" { 89 | if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" { 90 | Version = info.Main.Version 91 | } else { 92 | Version = "unknown (built from source)" 93 | } 94 | } 95 | rootCmd.Version = Version 96 | rootCmd.CompletionOptions.HiddenDefaultCmd = true 97 | 98 | rootCmd.AddCommand( 99 | cmd.BioCmd, 100 | cmd.IDCmd, 101 | cmd.JWTCmd, 102 | cmd.KeysCmd, 103 | cmd.LinkCmd("charm"), 104 | cmd.NameCmd, 105 | cmd.BackupKeysCmd, 106 | cmd.ImportKeysCmd, 107 | cmd.KeySyncCmd, 108 | cmd.CompletionCmd, 109 | cmd.ServeCmd, 110 | cmd.PostNewsCmd, 111 | cmd.KVCmd, 112 | cmd.FSCmd, 113 | cmd.CryptCmd, 114 | cmd.MigrateAccountCmd, 115 | cmd.WhereCmd, 116 | manCmd, 117 | ) 118 | } 119 | 120 | func main() { 121 | if err := rootCmd.Execute(); err != nil { 122 | os.Exit(1) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /proto/api.go: -------------------------------------------------------------------------------- 1 | // Package proto contains structs used for client/server communication. 2 | package proto 3 | 4 | // Message is used as a wrapper for simple client/server messages. 5 | type Message struct { 6 | Message string `json:"message"` 7 | } 8 | -------------------------------------------------------------------------------- /proto/auth.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | // Auth is the response to an authenticated connection. It contains tokens and 4 | // keys required to access Charm Cloud services. 5 | type Auth struct { 6 | JWT string `json:"jwt"` 7 | ID string `json:"charm_id"` 8 | HTTPScheme string `json:"http_scheme"` 9 | PublicKey string `json:"public_key,omitempty"` 10 | EncryptKeys []*EncryptKey `json:"encrypt_keys,omitempty"` 11 | } 12 | 13 | // Keys is the response returned when the user queries for the keys linked 14 | // to their account. 15 | type Keys struct { 16 | ActiveKey int `json:"active_key"` 17 | Keys []*PublicKey `json:"keys"` 18 | } 19 | -------------------------------------------------------------------------------- /proto/crypt.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // EncryptKey is the symmetric key used to encrypt data for a Charm user. An 8 | // encrypt key will be encoded for every public key associated with a user's 9 | // Charm account. 10 | type EncryptKey struct { 11 | ID string `json:"id"` 12 | Key string `json:"key"` 13 | PublicKey string `json:"public_key,omitempty"` 14 | CreatedAt *time.Time `json:"created_at"` 15 | } 16 | -------------------------------------------------------------------------------- /proto/errors.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // ErrMalformedKey parsing error for bad ssh key. 9 | var ErrMalformedKey = errors.New("malformed key; is it missing the algorithm type at the beginning?") 10 | 11 | // ErrMissingSSHAuth is used when the user is missing SSH credentials. 12 | var ErrMissingSSHAuth = errors.New("missing ssh auth") 13 | 14 | // ErrNameTaken is used when a user attempts to set a username and that 15 | // username is already taken. 16 | var ErrNameTaken = errors.New("name already taken") 17 | 18 | // ErrNameInvalid is used when a username is invalid. 19 | var ErrNameInvalid = errors.New("invalid name") 20 | 21 | // ErrCouldNotUnlinkKey is used when a key can't be deleted. 22 | var ErrCouldNotUnlinkKey = errors.New("could not unlink key") 23 | 24 | // ErrMissingUser is used when no user record is found. 25 | var ErrMissingUser = errors.New("no user found") 26 | 27 | // ErrUserExists is used when attempting to create a user with an existing 28 | // global id. 29 | var ErrUserExists = errors.New("user already exists for that key") 30 | 31 | // ErrPageOutOfBounds is an error for an invalid page number. 32 | var ErrPageOutOfBounds = errors.New("page must be a value of 1 or greater") 33 | 34 | // ErrTokenExists is used when attempting to create a token that already exists. 35 | var ErrTokenExists = errors.New("token already exists") 36 | 37 | // ErrAuthFailed indicates an authentication failure. The underlying error is 38 | // wrapped. 39 | type ErrAuthFailed struct { 40 | Err error 41 | } 42 | 43 | // Error returns the boxed error string. 44 | func (e ErrAuthFailed) Error() string { return fmt.Sprintf("authentication failed: %s", e.Err) } 45 | 46 | // Unwrap returns the boxed error. 47 | func (e ErrAuthFailed) Unwrap() error { return e.Err } 48 | -------------------------------------------------------------------------------- /proto/fs.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import ( 4 | "io/fs" 5 | "time" 6 | ) 7 | 8 | // FileInfo describes a file and is returned by Stat. 9 | type FileInfo struct { 10 | Name string `json:"name"` 11 | IsDir bool `json:"is_dir"` 12 | Size int64 `json:"size"` 13 | ModTime time.Time `json:"modtime"` 14 | Mode fs.FileMode `json:"mode"` 15 | Files []FileInfo `json:"files,omitempty"` 16 | } 17 | 18 | // Add execute permissions to an fs.FileMode to mirror read permissions. 19 | func AddExecPermsForMkDir(mode fs.FileMode) fs.FileMode { 20 | if mode.IsDir() { 21 | return mode 22 | } 23 | op := mode.Perm() 24 | if op&0400 == 0400 { 25 | op = op | 0100 26 | } 27 | if op&0040 == 0040 { 28 | op = op | 0010 29 | } 30 | if op&0004 == 0004 { 31 | op = op | 0001 32 | } 33 | return mode | op | fs.ModeDir 34 | } 35 | -------------------------------------------------------------------------------- /proto/kv.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | // SeqMsg represents the results of a named sequence. 4 | type SeqMsg struct { 5 | Seq uint64 `json:"seq"` 6 | } 7 | -------------------------------------------------------------------------------- /proto/link.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import "time" 4 | 5 | // LinkStatus represents a state in the linking process. 6 | type LinkStatus int 7 | 8 | // LinkStatus values. 9 | const ( 10 | LinkStatusInit LinkStatus = iota 11 | LinkStatusTokenCreated 12 | LinkStatusTokenSent 13 | LinkStatusRequested 14 | LinkStatusRequestDenied 15 | LinkStatusSameUser 16 | LinkStatusDifferentUser 17 | LinkStatusSuccess 18 | LinkStatusTimedOut 19 | LinkStatusError 20 | LinkStatusValidTokenRequest 21 | LinkStatusInvalidTokenRequest 22 | ) 23 | 24 | // LinkTimeout is the length of time a Token is valid for. 25 | const LinkTimeout = time.Minute 26 | 27 | // Token represent the confirmation code generated during linking. 28 | type Token string 29 | 30 | // Link is the struct used to communicate state during the account linking 31 | // process. 32 | type Link struct { 33 | Token Token `json:"token"` 34 | RequestPubKey string `json:"request_pub_key"` 35 | RequestAddr string `json:"request_addr"` 36 | Host string `json:"host"` 37 | Port int `json:"port"` 38 | Status LinkStatus `json:"status"` 39 | } 40 | 41 | // LinkHandler handles linking operations for the key to be linked. 42 | type LinkHandler interface { 43 | TokenCreated(*Link) 44 | TokenSent(*Link) 45 | ValidToken(*Link) 46 | InvalidToken(*Link) 47 | Request(*Link) bool 48 | RequestDenied(*Link) 49 | SameUser(*Link) 50 | Success(*Link) 51 | Timeout(*Link) 52 | Error(*Link) 53 | } 54 | 55 | // LinkTransport handles linking operations for the link generation. 56 | type LinkTransport interface { 57 | TokenCreated(Token) 58 | TokenSent(*Link) 59 | Requested(*Link) (bool, error) 60 | LinkedSameUser(*Link) 61 | LinkedDifferentUser(*Link) 62 | Success(*Link) 63 | TimedOut(*Link) 64 | Error(*Link) 65 | RequestStart(*Link) 66 | RequestDenied(*Link) 67 | RequestInvalidToken(*Link) 68 | RequestValidToken(*Link) 69 | User() *User 70 | } 71 | 72 | // UnlinkRequest is the message for unlinking an account from a key. 73 | type UnlinkRequest struct { 74 | Key string `json:"key"` 75 | } 76 | 77 | // LinkQueue handles creating, validating, and sending link requests. 78 | type LinkQueue interface { 79 | InitLinkRequest(t Token) 80 | WaitLinkRequest(t Token) (chan *Link, error) 81 | SendLinkRequest(lt LinkTransport, lc chan *Link, l *Link) 82 | ValidateLinkRequest(t Token) bool 83 | DeleteLinkRequest(t Token) 84 | } 85 | -------------------------------------------------------------------------------- /proto/news.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import "time" 4 | 5 | // News entity. 6 | type News struct { 7 | ID string `json:"id"` 8 | Subject string `json:"subject"` 9 | Tag string `json:"tag"` 10 | Body string `json:"body,omitempty"` 11 | CreatedAt time.Time `json:"created_at"` 12 | } 13 | -------------------------------------------------------------------------------- /proto/user.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import ( 4 | "crypto/sha1" // nolint: gosec 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // User represents a Charm user account. 10 | type User struct { 11 | ID int `json:"id"` 12 | CharmID string `json:"charm_id"` 13 | PublicKey *PublicKey `json:"public_key,omitempty"` 14 | Name string `json:"name"` 15 | Email string `json:"email"` 16 | Bio string `json:"bio"` 17 | CreatedAt *time.Time `json:"created_at"` 18 | } 19 | 20 | // PublicKey represents to public SSH key for a Charm user. 21 | type PublicKey struct { 22 | ID int `json:"id"` 23 | UserID int `json:"user_id,omitempty"` 24 | Key string `json:"key"` 25 | CreatedAt *time.Time `json:"created_at"` 26 | } 27 | 28 | // Sha returns the SHA for the public key in hex format. 29 | func (pk *PublicKey) Sha() string { 30 | return PublicKeySha(pk.Key) 31 | } 32 | 33 | // PublicKeySha returns the SHA for a public key in hex format. 34 | func PublicKeySha(key string) string { 35 | return fmt.Sprintf("%x", sha1.Sum([]byte(key))) // nolint: gosec 36 | } 37 | -------------------------------------------------------------------------------- /server/auth.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/log" 7 | 8 | charm "github.com/charmbracelet/charm/proto" 9 | "github.com/charmbracelet/ssh" 10 | "github.com/charmbracelet/wish" 11 | ) 12 | 13 | func (me *SSHServer) sshMiddleware() wish.Middleware { 14 | return func(sh ssh.Handler) ssh.Handler { 15 | return func(s ssh.Session) { 16 | cmd := s.Command() 17 | if len(cmd) >= 1 { 18 | r := cmd[0] 19 | log.Debug("ssh", "cmd", r) 20 | switch r { 21 | case "api-auth": 22 | me.handleAPIAuth(s) 23 | case "api-keys": 24 | me.handleAPIKeys(s) 25 | case "api-link": 26 | me.handleAPILink(s) 27 | case "api-unlink": 28 | me.handleAPIUnlink(s) 29 | case "id": 30 | me.handleID(s) 31 | case "jwt": 32 | me.handleJWT(s) 33 | } 34 | } 35 | sh(s) 36 | } 37 | } 38 | } 39 | 40 | func (me *SSHServer) handleAPIAuth(s ssh.Session) { 41 | key, err := keyText(s) 42 | if err != nil { 43 | me.errorLog.Print(err) 44 | return 45 | } 46 | u, err := me.db.UserForKey(key, true) 47 | if err != nil { 48 | me.errorLog.Print(err) 49 | return 50 | } 51 | log.Debug("JWT for user", "id", u.CharmID) 52 | j, err := me.newJWT(u.CharmID, "charm") 53 | if err != nil { 54 | me.errorLog.Printf("Error making JWT: %s\n", err) 55 | return 56 | } 57 | 58 | eks, err := me.db.EncryptKeysForPublicKey(u.PublicKey) 59 | if err != nil { 60 | me.errorLog.Printf("Error fetching encrypt keys: %s\n", err) 61 | return 62 | } 63 | httpScheme := me.config.httpURL().Scheme 64 | _ = me.sendJSON(s, charm.Auth{ 65 | JWT: j, 66 | ID: u.CharmID, 67 | HTTPScheme: httpScheme, 68 | PublicKey: u.PublicKey.Key, 69 | EncryptKeys: eks, 70 | }) 71 | me.config.Stats.APIAuth() 72 | } 73 | 74 | func (me *SSHServer) handleAPIKeys(s ssh.Session) { 75 | key, err := keyText(s) 76 | if err != nil { 77 | me.errorLog.Print(err) 78 | _ = me.sendAPIMessage(s, "Missing key") 79 | return 80 | } 81 | u, err := me.db.UserForKey(key, true) 82 | if err != nil { 83 | me.errorLog.Print(err) 84 | _ = me.sendAPIMessage(s, fmt.Sprintf("API keys error: %s", err)) 85 | return 86 | } 87 | log.Debug("API keys for user", "id", u.CharmID) 88 | keys, err := me.db.KeysForUser(u) 89 | if err != nil { 90 | me.errorLog.Print(err) 91 | _ = me.sendAPIMessage(s, "There was a problem fetching your keys") 92 | return 93 | } 94 | 95 | // Find index of the key currently in use 96 | activeKey := -1 97 | for i, k := range keys { 98 | if k.Key == u.PublicKey.Key { 99 | activeKey = i 100 | break 101 | } 102 | } 103 | 104 | _ = me.sendJSON(s, charm.Keys{ 105 | ActiveKey: activeKey, 106 | Keys: keys, 107 | }) 108 | me.config.Stats.APIKeys() 109 | } 110 | 111 | func (me *SSHServer) handleID(s ssh.Session) { 112 | key, err := keyText(s) 113 | if err != nil { 114 | me.errorLog.Print(err) 115 | return 116 | } 117 | u, err := me.db.UserForKey(key, true) 118 | if err != nil { 119 | me.errorLog.Print(err) 120 | return 121 | } 122 | log.Debug("ID for user", "id", u.CharmID) 123 | _, _ = s.Write([]byte(u.CharmID)) 124 | me.config.Stats.ID() 125 | } 126 | -------------------------------------------------------------------------------- /server/auth_test.go: -------------------------------------------------------------------------------- 1 | package server_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/charmbracelet/charm/testserver" 7 | ) 8 | 9 | func TestSSHAuthMiddleware(t *testing.T) { 10 | cl := testserver.SetupTestServer(t) 11 | auth, err := cl.Auth() 12 | if err != nil { 13 | t.Fatalf("auth error: %s", err) 14 | } 15 | if auth.JWT == "" { 16 | t.Fatal("auth error, missing JWT") 17 | } 18 | if auth.ID == "" { 19 | t.Fatal("auth error, missing ID") 20 | } 21 | if auth.PublicKey == "" { 22 | t.Fatal("auth error, missing PublicKey") 23 | } 24 | // if len(auth.EncryptKeys) == 0 { 25 | // t.Fatal("auth error, missing EncryptKeys") 26 | // } 27 | } 28 | -------------------------------------------------------------------------------- /server/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "time" 5 | 6 | charm "github.com/charmbracelet/charm/proto" 7 | ) 8 | 9 | // DB specifies the business logic methods a datastore must implement as the 10 | // Charm Cloud backend. 11 | type DB interface { 12 | UserForKey(key string, create bool) (*charm.User, error) 13 | LinkUserKey(user *charm.User, key string) error 14 | UnlinkUserKey(user *charm.User, key string) error 15 | KeysForUser(user *charm.User) ([]*charm.PublicKey, error) 16 | MergeUsers(userID1 int, userID2 int) error 17 | EncryptKeysForPublicKey(pk *charm.PublicKey) ([]*charm.EncryptKey, error) 18 | AddEncryptKeyForPublicKey(user *charm.User, publicKey string, globalID string, encryptedKey string, createdAt *time.Time) error 19 | GetUserWithID(charmID string) (*charm.User, error) 20 | GetUserWithName(name string) (*charm.User, error) 21 | SetUserName(charmID string, name string) (*charm.User, error) 22 | UserCount() (int, error) 23 | UserNameCount() (int, error) 24 | NextSeq(user *charm.User, name string) (uint64, error) 25 | GetSeq(user *charm.User, name string) (uint64, error) 26 | PostNews(subject string, body string, tags []string) error 27 | GetNews(id string) (*charm.News, error) 28 | GetNewsList(tag string, page int) ([]*charm.News, error) 29 | SetToken(token charm.Token) error 30 | DeleteToken(token charm.Token) error 31 | Close() error 32 | } 33 | -------------------------------------------------------------------------------- /server/db/sqlite/migration/0001_foreign_keys.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | // Migration0001 is the initial migration. 4 | var Migration0001 = Migration{ 5 | ID: 1, 6 | Name: "foreign keys", 7 | SQL: ` 8 | PRAGMA foreign_keys=off; 9 | 10 | /* public_key */ 11 | ALTER TABLE public_key RENAME TO _public_key; 12 | 13 | CREATE TABLE public_key( 14 | id INTEGER NOT NULL PRIMARY KEY, 15 | user_id integer NOT NULL, 16 | public_key varchar(2048) NOT NULL, 17 | created_at timestamp default current_timestamp, 18 | UNIQUE (user_id, public_key), 19 | CONSTRAINT user_id_fk 20 | FOREIGN KEY (user_id) 21 | REFERENCES charm_user (id) 22 | ON DELETE CASCADE 23 | ON UPDATE CASCADE 24 | ); 25 | 26 | INSERT INTO public_key SELECT * FROM _public_key; 27 | /* public_key */ 28 | 29 | /* encrypt_key */ 30 | ALTER TABLE encrypt_key RENAME TO _encrypt_key; 31 | 32 | CREATE TABLE encrypt_key( 33 | id INTEGER NOT NULL PRIMARY KEY, 34 | public_key_id integer NOT NULL, 35 | global_id uuid NOT NULL, 36 | created_at timestamp default current_timestamp, 37 | encrypted_key varchar(2048) NOT NULL, 38 | CONSTRAINT public_key_id_fk 39 | FOREIGN KEY (public_key_id) 40 | REFERENCES public_key (id) 41 | ON DELETE CASCADE 42 | ON UPDATE CASCADE 43 | ); 44 | 45 | INSERT INTO encrypt_key SELECT * FROM _encrypt_key; 46 | /* encrypt_key */ 47 | 48 | /* named_seq */ 49 | ALTER TABLE named_seq RENAME TO _named_seq; 50 | 51 | CREATE TABLE named_seq( 52 | id INTEGER NOT NULL PRIMARY KEY, 53 | user_id integer NOT NULL, 54 | seq integer NOT NULL DEFAULT 0, 55 | name varchar(1024) NOT NULL, 56 | UNIQUE (user_id, name), 57 | CONSTRAINT user_id_fk 58 | FOREIGN KEY (user_id) 59 | REFERENCES charm_user (id) 60 | ON DELETE CASCADE 61 | ON UPDATE CASCADE 62 | ); 63 | 64 | INSERT INTO named_seq SELECT * FROM _named_seq; 65 | /* named_seq */ 66 | 67 | /* news_tag */ 68 | ALTER TABLE news_tag RENAME TO _news_tag; 69 | 70 | CREATE TABLE news_tag( 71 | id INTEGER NOT NULL PRIMARY KEY, 72 | tag varchar(250), 73 | news_id integer NOT NULL, 74 | CONSTRAINT news_id_fk 75 | FOREIGN KEY (news_id) 76 | REFERENCES news (id) 77 | ON DELETE CASCADE 78 | ON UPDATE CASCADE 79 | ); 80 | 81 | INSERT INTO news_tag SELECT * FROM _news_tag; 82 | /* news_tag */ 83 | 84 | DROP TABLE _public_key; 85 | DROP TABLE _encrypt_key; 86 | DROP TABLE _named_seq; 87 | DROP TABLE _news_tag; 88 | 89 | PRAGMA foreign_keys=on; 90 | `, 91 | } 92 | -------------------------------------------------------------------------------- /server/db/sqlite/migration/migration.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | // Migration is a db migration script. 4 | type Migration struct { 5 | ID int 6 | Name string 7 | SQL string 8 | } 9 | -------------------------------------------------------------------------------- /server/db/sqlite/sql.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | const ( 4 | sqlCreateUserTable = `CREATE TABLE IF NOT EXISTS charm_user( 5 | id INTEGER NOT NULL PRIMARY KEY, 6 | charm_id uuid UNIQUE NOT NULL, 7 | name varchar(50) UNIQUE, 8 | email varchar(254), 9 | bio varchar(1000), 10 | created_at timestamp default current_timestamp 11 | )` 12 | 13 | sqlCreatePublicKeyTable = `CREATE TABLE IF NOT EXISTS public_key( 14 | id INTEGER NOT NULL PRIMARY KEY, 15 | user_id integer NOT NULL, 16 | public_key varchar(2048) NOT NULL, 17 | created_at timestamp default current_timestamp, 18 | UNIQUE (user_id, public_key), 19 | CONSTRAINT user_id_fk 20 | FOREIGN KEY (user_id) 21 | REFERENCES charm_user (id) 22 | ON DELETE CASCADE 23 | ON UPDATE CASCADE 24 | )` 25 | 26 | sqlCreateEncryptKeyTable = `CREATE TABLE IF NOT EXISTS encrypt_key( 27 | id INTEGER NOT NULL PRIMARY KEY, 28 | public_key_id integer NOT NULL, 29 | global_id uuid NOT NULL, 30 | created_at timestamp default current_timestamp, 31 | encrypted_key varchar(2048) NOT NULL, 32 | CONSTRAINT public_key_id_fk 33 | FOREIGN KEY (public_key_id) 34 | REFERENCES public_key (id) 35 | ON DELETE CASCADE 36 | ON UPDATE CASCADE 37 | )` 38 | 39 | sqlCreateNamedSeqTable = `CREATE TABLE IF NOT EXISTS named_seq( 40 | id INTEGER NOT NULL PRIMARY KEY, 41 | user_id integer NOT NULL, 42 | seq integer NOT NULL DEFAULT 0, 43 | name varchar(1024) NOT NULL, 44 | UNIQUE (user_id, name), 45 | CONSTRAINT user_id_fk 46 | FOREIGN KEY (user_id) 47 | REFERENCES charm_user (id) 48 | ON DELETE CASCADE 49 | ON UPDATE CASCADE 50 | )` 51 | 52 | sqlCreateNewsTable = `CREATE TABLE IF NOT EXISTS news( 53 | id INTEGER NOT NULL PRIMARY KEY, 54 | subject text, 55 | body text, 56 | created_at timestamp default current_timestamp 57 | )` 58 | 59 | sqlCreateNewsTagTable = `CREATE TABLE IF NOT EXISTS news_tag( 60 | id INTEGER NOT NULL PRIMARY KEY, 61 | tag varchar(250), 62 | news_id integer NOT NULL, 63 | CONSTRAINT news_id_fk 64 | FOREIGN KEY (news_id) 65 | REFERENCES news (id) 66 | ON DELETE CASCADE 67 | ON UPDATE CASCADE 68 | )` 69 | 70 | sqlCreateTokenTable = `CREATE TABLE IF NOT EXISTS token( 71 | id INTEGER NOT NULL PRIMARY KEY, 72 | pin text UNIQUE NOT NULL, 73 | created_at timestamp default current_timestamp 74 | )` 75 | 76 | sqlSelectUserWithName = `SELECT id, charm_id, name, email, bio, created_at FROM charm_user WHERE name like ?` 77 | sqlSelectUserWithCharmID = `SELECT id, charm_id, name, email, bio, created_at FROM charm_user WHERE charm_id = ?` 78 | sqlSelectUserWithID = `SELECT id, charm_id, name, email, bio, created_at FROM charm_user WHERE id = ?` 79 | sqlSelectUserPublicKeys = `SELECT id, public_key, created_at FROM public_key WHERE user_id = ?` 80 | sqlSelectNumberUserPublicKeys = `SELECT count(*) FROM public_key WHERE user_id = ?` 81 | sqlSelectPublicKey = `SELECT id, user_id, public_key FROM public_key WHERE public_key = ?` 82 | sqlSelectEncryptKey = `SELECT global_id, encrypted_key, created_at FROM encrypt_key WHERE public_key_id = ? AND global_id = ?` 83 | sqlSelectEncryptKeys = `SELECT global_id, encrypted_key, created_at FROM encrypt_key WHERE public_key_id = ? ORDER BY created_at ASC` 84 | sqlSelectNamedSeq = `SELECT seq FROM named_seq WHERE user_id = ? AND name = ?` 85 | 86 | sqlInsertUser = `INSERT INTO charm_user (charm_id) VALUES (?)` 87 | 88 | sqlInsertPublicKey = `INSERT INTO public_key (user_id, public_key) VALUES (?, ?) 89 | ON CONFLICT (user_id, public_key) DO UPDATE SET 90 | user_id = excluded.user_id, 91 | public_key = excluded.public_key` 92 | sqlInsertNews = `INSERT INTO news (subject, body) VALUES (?,?)` 93 | sqlInsertNewsTag = `INSERT INTO news_tag (news_id, tag) VALUES (?,?)` 94 | 95 | sqlIncNamedSeq = `INSERT INTO named_seq (user_id, name) 96 | VALUES(?,?) 97 | ON CONFLICT (user_id, name) DO UPDATE SET 98 | user_id = excluded.user_id, 99 | name = excluded.name, 100 | seq = seq + 1` 101 | 102 | sqlInsertEncryptKey = `INSERT INTO encrypt_key (encrypted_key, global_id, public_key_id) VALUES (?, ?, ?)` 103 | sqlInsertEncryptKeyWithDate = `INSERT INTO encrypt_key (encrypted_key, global_id, public_key_id, created_at) VALUES (?, ?, ?, ?)` 104 | 105 | sqlInsertToken = `INSERT INTO token (pin) VALUES (?)` 106 | 107 | sqlUpdateUser = `UPDATE charm_user SET name = ? WHERE charm_id = ?` 108 | sqlUpdateMergePublicKeys = `UPDATE public_key SET user_id = ? WHERE user_id = ?` 109 | 110 | sqlDeleteUserPublicKey = `DELETE FROM public_key WHERE user_id = ? AND public_key = ?` 111 | sqlDeleteUser = `DELETE FROM charm_user WHERE id = ?` 112 | 113 | sqlDeleteToken = `DELETE FROM token WHERE pin = ?` 114 | 115 | sqlCountUsers = `SELECT COUNT(*) FROM charm_user` 116 | sqlCountUserNames = `SELECT COUNT(*) FROM charm_user WHERE name <> ''` 117 | 118 | sqlSelectNews = `SELECT id, subject, body, created_at FROM news WHERE id = ?` 119 | sqlSelectNewsList = `SELECT n.id, n.subject, n.created_at FROM news AS n 120 | INNER JOIN news_tag AS t ON t.news_id = n.id 121 | WHERE t.tag = ? 122 | ORDER BY n.created_at desc 123 | LIMIT 50 OFFSET ?` 124 | ) 125 | -------------------------------------------------------------------------------- /server/http_test.go: -------------------------------------------------------------------------------- 1 | package server_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/charmbracelet/charm/testserver" 7 | ) 8 | 9 | func TestHTTPAccess(t *testing.T) { 10 | cl := testserver.SetupTestServer(t) 11 | _, err := cl.Auth() 12 | if err != nil { 13 | t.Fatalf("auth error: %s", err) 14 | } 15 | 16 | _, err = cl.AuthedRawRequest("GET", "/v1/fs/../../db/charm_sqlite.db") 17 | if err == nil { 18 | t.Fatalf("expected access error, got nil") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/jwk.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "crypto/sha256" 6 | "fmt" 7 | 8 | "gopkg.in/go-jose/go-jose.v2" 9 | ) 10 | 11 | // JSONWebKeyPair holds the ED25519 private key and JSON Web Key used in JWT 12 | // operations. 13 | type JSONWebKeyPair struct { 14 | PrivateKey *ed25519.PrivateKey 15 | JWK jose.JSONWebKey 16 | } 17 | 18 | // NewJSONWebKeyPair creates a new JSONWebKeyPair from a given ED25519 private 19 | // key. 20 | func NewJSONWebKeyPair(pk *ed25519.PrivateKey) JSONWebKeyPair { 21 | sum := sha256.Sum256([]byte(*pk)) 22 | kid := fmt.Sprintf("%x", sum) 23 | jwk := jose.JSONWebKey{ 24 | Key: pk.Public(), 25 | KeyID: kid, 26 | Algorithm: "EdDSA", 27 | } 28 | return JSONWebKeyPair{ 29 | PrivateKey: pk, 30 | JWK: jwk, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/middleware.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/charmbracelet/log" 10 | "gopkg.in/go-jose/go-jose.v2" 11 | 12 | jwtmiddleware "github.com/auth0/go-jwt-middleware/v2" 13 | "github.com/auth0/go-jwt-middleware/v2/validator" 14 | charm "github.com/charmbracelet/charm/proto" 15 | ) 16 | 17 | type contextKey string 18 | 19 | var ( 20 | ctxUserKey contextKey = "charmUser" 21 | ctxPublicKey contextKey = "public" 22 | ) 23 | 24 | // MaxFSRequestSize is the maximum size of a request body for fs endpoints. 25 | var MaxFSRequestSize int64 = 1024 * 1024 * 1024 // 1GB 26 | 27 | // RequestLimitMiddleware limits the request body size to the specified limit. 28 | func RequestLimitMiddleware() func(http.Handler) http.Handler { 29 | return func(h http.Handler) http.Handler { 30 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | var maxRequestSize int64 32 | if strings.HasPrefix(r.URL.Path, "/v1/fs") { 33 | maxRequestSize = MaxFSRequestSize 34 | } else { 35 | maxRequestSize = 1024 * 1024 // limit request size to 1MB for other endpoints 36 | } 37 | // Check if the request body is too large using Content-Length 38 | if r.ContentLength > maxRequestSize { 39 | http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge) 40 | return 41 | } 42 | // Limit body read using MaxBytesReader 43 | r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) 44 | h.ServeHTTP(w, r) 45 | }) 46 | } 47 | } 48 | 49 | // PublicPrefixesMiddleware allows for the specification of non-authed URL 50 | // prefixes. These won't be checked for JWT bearers or Charm user accounts. 51 | func PublicPrefixesMiddleware(prefixes []string) func(http.Handler) http.Handler { 52 | return func(next http.Handler) http.Handler { 53 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 54 | public := false 55 | for _, p := range prefixes { 56 | if strings.HasPrefix(r.URL.Path, p) { 57 | public = true 58 | } 59 | } 60 | ctx := context.WithValue(r.Context(), ctxPublicKey, public) 61 | next.ServeHTTP(w, r.WithContext(ctx)) 62 | }) 63 | } 64 | } 65 | 66 | // JWTMiddleware creates a new middleware function that will validate JWT 67 | // tokens based on the supplied public key. 68 | func JWTMiddleware(pk jose.JSONWebKey, iss string, aud []string) (func(http.Handler) http.Handler, error) { 69 | jm, err := jwtMiddlewareImpl(pk, iss, aud) 70 | if err != nil { 71 | return nil, err 72 | } 73 | return func(next http.Handler) http.Handler { 74 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 75 | if isPublic(r) { 76 | next.ServeHTTP(w, r) 77 | } else { 78 | jm(next).ServeHTTP(w, r) 79 | } 80 | }) 81 | }, nil 82 | } 83 | 84 | // CharmUserMiddleware looks up and authenticates a Charm user based on the 85 | // provided JWT in the request. 86 | func CharmUserMiddleware(s *HTTPServer) func(http.Handler) http.Handler { 87 | return func(h http.Handler) http.Handler { 88 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 89 | if isPublic(r) { 90 | h.ServeHTTP(w, r) 91 | } else { 92 | id, err := charmIDFromRequest(r) 93 | if err != nil { 94 | log.Error("cannot get charm id from request", "err", err) 95 | s.renderError(w) 96 | return 97 | } 98 | u, err := s.db.GetUserWithID(id) 99 | if err == charm.ErrMissingUser { 100 | s.renderCustomError(w, fmt.Sprintf("missing user for id '%s'", id), http.StatusNotFound) 101 | return 102 | } else if err != nil { 103 | log.Error("cannot read request body", "err", err) 104 | s.renderError(w) 105 | return 106 | } 107 | ctx := context.WithValue(r.Context(), ctxUserKey, u) 108 | h.ServeHTTP(w, r.WithContext(ctx)) 109 | } 110 | }) 111 | } 112 | } 113 | 114 | func isPublic(r *http.Request) bool { 115 | public, ok := r.Context().Value(ctxPublicKey).(bool) 116 | if !ok { 117 | log.Debug("cannot get public value from context") 118 | return false 119 | } 120 | 121 | return public 122 | } 123 | 124 | func charmIDFromRequest(r *http.Request) (string, error) { 125 | claims := r.Context().Value(jwtmiddleware.ContextKey{}) 126 | if claims == "" { 127 | return "", fmt.Errorf("missing jwt claims key in context") 128 | } 129 | cl := claims.(*validator.ValidatedClaims).RegisteredClaims 130 | sub := cl.Subject 131 | if sub == "" { 132 | return "", fmt.Errorf("missing subject key in claims map") 133 | } 134 | return sub, nil 135 | } 136 | 137 | func jwtMiddlewareImpl(pk jose.JSONWebKey, iss string, aud []string) (func(http.Handler) http.Handler, error) { 138 | kf := func(context.Context) (interface{}, error) { 139 | jwks := jose.JSONWebKeySet{ 140 | Keys: []jose.JSONWebKey{pk}, 141 | } 142 | return &jwks, nil 143 | } 144 | v, err := validator.New( 145 | kf, 146 | validator.EdDSA, 147 | iss, 148 | aud, 149 | ) 150 | if err != nil { 151 | return nil, err 152 | } 153 | mw := jwtmiddleware.New(v.ValidateToken) 154 | return mw.CheckJWT, nil 155 | } 156 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | // Package server provides a Charm Cloud server with HTTP and SSH protocols. 2 | package server 3 | 4 | import ( 5 | "context" 6 | "crypto/ed25519" 7 | "crypto/tls" 8 | "fmt" 9 | glog "log" 10 | "net/url" 11 | "path/filepath" 12 | 13 | env "github.com/caarlos0/env/v6" 14 | charm "github.com/charmbracelet/charm/proto" 15 | "github.com/charmbracelet/charm/server/db" 16 | "github.com/charmbracelet/charm/server/db/sqlite" 17 | "github.com/charmbracelet/charm/server/stats" 18 | "github.com/charmbracelet/charm/server/stats/noop" 19 | "github.com/charmbracelet/charm/server/stats/prometheus" 20 | "github.com/charmbracelet/charm/server/storage" 21 | lfs "github.com/charmbracelet/charm/server/storage/local" 22 | "github.com/charmbracelet/log" 23 | gossh "golang.org/x/crypto/ssh" 24 | "golang.org/x/sync/errgroup" 25 | ) 26 | 27 | // Config is the configuration for the Charm server. 28 | type Config struct { 29 | BindAddr string `env:"CHARM_SERVER_BIND_ADDRESS" envDefault:""` 30 | Host string `env:"CHARM_SERVER_HOST" envDefault:"localhost"` 31 | SSHPort int `env:"CHARM_SERVER_SSH_PORT" envDefault:"35353"` 32 | HTTPPort int `env:"CHARM_SERVER_HTTP_PORT" envDefault:"35354"` 33 | StatsPort int `env:"CHARM_SERVER_STATS_PORT" envDefault:"35355"` 34 | HealthPort int `env:"CHARM_SERVER_HEALTH_PORT" envDefault:"35356"` 35 | DataDir string `env:"CHARM_SERVER_DATA_DIR" envDefault:"data"` 36 | UseTLS bool `env:"CHARM_SERVER_USE_TLS" envDefault:"false"` 37 | TLSKeyFile string `env:"CHARM_SERVER_TLS_KEY_FILE"` 38 | TLSCertFile string `env:"CHARM_SERVER_TLS_CERT_FILE"` 39 | PublicURL string `env:"CHARM_SERVER_PUBLIC_URL"` 40 | EnableMetrics bool `env:"CHARM_SERVER_ENABLE_METRICS" envDefault:"false"` 41 | UserMaxStorage int64 `env:"CHARM_SERVER_USER_MAX_STORAGE" envDefault:"0"` 42 | errorLog *glog.Logger 43 | PublicKey []byte 44 | PrivateKey []byte 45 | DB db.DB 46 | FileStore storage.FileStore 47 | Stats stats.Stats 48 | linkQueue charm.LinkQueue 49 | tlsConfig *tls.Config 50 | jwtKeyPair JSONWebKeyPair 51 | httpScheme string 52 | } 53 | 54 | // Server contains the SSH and HTTP servers required to host the Charm Cloud. 55 | type Server struct { 56 | Config *Config 57 | ssh *SSHServer 58 | http *HTTPServer 59 | } 60 | 61 | // DefaultConfig returns a Config with the values populated with the defaults 62 | // or specified environment variables. 63 | func DefaultConfig() *Config { 64 | cfg := &Config{httpScheme: "http"} 65 | if err := env.Parse(cfg); err != nil { 66 | log.Fatal("could not read environment", "err", err) 67 | } 68 | 69 | return cfg 70 | } 71 | 72 | // WithDB returns a Config with the provided DB interface implementation. 73 | func (cfg *Config) WithDB(db db.DB) *Config { 74 | cfg.DB = db 75 | return cfg 76 | } 77 | 78 | // WithFileStore returns a Config with the provided FileStore implementation. 79 | func (cfg *Config) WithFileStore(fs storage.FileStore) *Config { 80 | cfg.FileStore = fs 81 | return cfg 82 | } 83 | 84 | // WithStats returns a Config with the provided Stats implementation. 85 | func (cfg *Config) WithStats(s stats.Stats) *Config { 86 | cfg.Stats = s 87 | return cfg 88 | } 89 | 90 | // WithKeys returns a Config with the provided public and private keys for the 91 | // SSH server and JWT signing. 92 | func (cfg *Config) WithKeys(publicKey []byte, privateKey []byte) *Config { 93 | cfg.PublicKey = publicKey 94 | cfg.PrivateKey = privateKey 95 | return cfg 96 | } 97 | 98 | // WithTLSConfig returns a Config with the provided TLS configuration. 99 | func (cfg *Config) WithTLSConfig(c *tls.Config) *Config { 100 | cfg.tlsConfig = c 101 | return cfg 102 | } 103 | 104 | // WithErrorLogger returns a Config with the provided error log for the server. 105 | func (cfg *Config) WithErrorLogger(l *glog.Logger) *Config { 106 | cfg.errorLog = l 107 | return cfg 108 | } 109 | 110 | // WithLinkQueue returns a Config with the provided LinkQueue implementation. 111 | func (cfg *Config) WithLinkQueue(q charm.LinkQueue) *Config { 112 | cfg.linkQueue = q 113 | return cfg 114 | } 115 | 116 | func (cfg *Config) httpURL() *url.URL { 117 | s := fmt.Sprintf("%s://%s:%d", cfg.httpScheme, cfg.Host, cfg.HTTPPort) 118 | if cfg.PublicURL != "" { 119 | s = cfg.PublicURL 120 | } 121 | url, err := url.Parse(s) 122 | if err != nil { 123 | log.Fatal("could not parse URL", "err", err) 124 | } 125 | return url 126 | } 127 | 128 | // NewServer returns a *Server with the specified Config. 129 | func NewServer(cfg *Config) (*Server, error) { 130 | s := &Server{Config: cfg} 131 | s.init(cfg) 132 | 133 | pk, err := gossh.ParseRawPrivateKey(cfg.PrivateKey) 134 | if err != nil { 135 | return nil, err 136 | } 137 | cfg.jwtKeyPair = NewJSONWebKeyPair(pk.(*ed25519.PrivateKey)) 138 | ss, err := NewSSHServer(cfg) 139 | if err != nil { 140 | return nil, err 141 | } 142 | s.ssh = ss 143 | hs, err := NewHTTPServer(cfg) 144 | if err != nil { 145 | return nil, err 146 | } 147 | s.http = hs 148 | return s, nil 149 | } 150 | 151 | // Start starts the HTTP, SSH and health HTTP servers for the Charm Cloud. 152 | func (srv *Server) Start() error { 153 | errg := errgroup.Group{} 154 | if srv.Config.Stats != nil { 155 | errg.Go(func() error { 156 | return srv.Config.Stats.Start() 157 | }) 158 | } 159 | errg.Go(func() error { 160 | return srv.http.Start() 161 | }) 162 | errg.Go(func() error { 163 | return srv.ssh.Start() 164 | }) 165 | return errg.Wait() 166 | } 167 | 168 | // Shutdown shuts down the HTTP, and SSH and health HTTP servers for the Charm Cloud. 169 | func (srv *Server) Shutdown(ctx context.Context) error { 170 | if srv.Config.Stats != nil { 171 | if err := srv.Config.Stats.Shutdown(ctx); err != nil { 172 | return err 173 | } 174 | } 175 | if err := srv.ssh.Shutdown(ctx); err != nil { 176 | return err 177 | } 178 | return srv.http.Shutdown(ctx) 179 | } 180 | 181 | // Close immediately closes all active net.Listeners for the HTTP, HTTP health and SSH servers. 182 | func (srv *Server) Close() error { 183 | herr := srv.http.server.Close() 184 | hherr := srv.http.health.Close() 185 | serr := srv.ssh.server.Close() 186 | if herr != nil || hherr != nil || serr != nil { 187 | return fmt.Errorf("one or more servers had an error closing: %s %s %s", herr, hherr, serr) 188 | } 189 | err := srv.Config.DB.Close() 190 | if err != nil { 191 | return fmt.Errorf("db close error: %s", err) 192 | } 193 | if srv.Config.Stats != nil { 194 | if err := srv.Config.Stats.Close(); err != nil { 195 | return fmt.Errorf("db close error: %s", err) 196 | } 197 | } 198 | return nil 199 | } 200 | 201 | func (srv *Server) init(cfg *Config) { 202 | if cfg.DB == nil { 203 | dp := filepath.Join(cfg.DataDir, "db") 204 | err := storage.EnsureDir(dp, 0o700) 205 | if err != nil { 206 | log.Fatal("could not init sqlite path", "err", err) 207 | } 208 | db := sqlite.NewDB(filepath.Join(dp, sqlite.DbName)) 209 | srv.Config = cfg.WithDB(db) 210 | } 211 | if cfg.FileStore == nil { 212 | fs, err := lfs.NewLocalFileStore(filepath.Join(cfg.DataDir, "files")) 213 | if err != nil { 214 | log.Fatal("could not init file path", "err", err) 215 | } 216 | srv.Config = cfg.WithFileStore(fs) 217 | } 218 | if cfg.Stats == nil { 219 | srv.Config = cfg.WithStats(getStatsImpl(cfg)) 220 | } 221 | } 222 | 223 | func getStatsImpl(cfg *Config) stats.Stats { 224 | if cfg.EnableMetrics { 225 | return prometheus.NewStats(cfg.DB, cfg.StatsPort) 226 | } 227 | return noop.Stats{} 228 | } 229 | -------------------------------------------------------------------------------- /server/ssh.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | glog "log" 9 | "os" 10 | "path/filepath" 11 | "time" 12 | 13 | "github.com/charmbracelet/log" 14 | 15 | charm "github.com/charmbracelet/charm/proto" 16 | "github.com/charmbracelet/charm/server/db" 17 | "github.com/charmbracelet/ssh" 18 | "github.com/charmbracelet/wish" 19 | rm "github.com/charmbracelet/wish/recover" 20 | jwt "github.com/golang-jwt/jwt/v4" 21 | ) 22 | 23 | // Session represents a Charm User's SSH session. 24 | type Session struct { 25 | ssh.Session 26 | } 27 | 28 | // SessionHandler defines a function that handles a session for a given SSH 29 | // command. 30 | type SessionHandler func(s Session) 31 | 32 | // SSHServer serves the SSH protocol and handles requests to authenticate and 33 | // link Charm user accounts. 34 | type SSHServer struct { 35 | config *Config 36 | db db.DB 37 | server *ssh.Server 38 | errorLog *glog.Logger 39 | linkQueue charm.LinkQueue 40 | } 41 | 42 | // NewSSHServer creates a new SSHServer from the provided Config. 43 | func NewSSHServer(cfg *Config) (*SSHServer, error) { 44 | s := &SSHServer{ 45 | config: cfg, 46 | errorLog: cfg.errorLog, 47 | linkQueue: cfg.linkQueue, 48 | } 49 | 50 | if s.errorLog == nil { 51 | s.errorLog = log.StandardLog(log.StandardLogOptions{ 52 | ForceLevel: log.ErrorLevel, 53 | }) 54 | } 55 | addr := fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.SSHPort) 56 | s.db = cfg.DB 57 | if s.linkQueue == nil { 58 | s.linkQueue = &channelLinkQueue{ 59 | s: s, 60 | linkRequests: make(map[charm.Token]chan *charm.Link), 61 | } 62 | } 63 | opts := []ssh.Option{ 64 | wish.WithAddress(addr), 65 | wish.WithHostKeyPEM(cfg.PrivateKey), 66 | wish.WithPublicKeyAuth(s.authHandler), 67 | wish.WithMiddleware( 68 | rm.MiddlewareWithLogger( 69 | log.NewWithOptions(os.Stderr, log.Options{Level: log.ErrorLevel}), 70 | s.sshMiddleware(), 71 | ), 72 | ), 73 | } 74 | fp := filepath.Join(cfg.DataDir, ".ssh", "authorized_keys") 75 | if _, err := os.Stat(fp); err == nil { 76 | log.Debug("Loading authorized_keys from", "path", fp) 77 | opts = append(opts, wish.WithAuthorizedKeys(fp)) 78 | } 79 | srv, err := wish.NewServer(opts...) 80 | if err != nil { 81 | return nil, err 82 | } 83 | s.server = srv 84 | return s, nil 85 | } 86 | 87 | // Start serves the SSH protocol on the configured port. 88 | func (me *SSHServer) Start() error { 89 | log.Print("Starting SSH server", "addr", me.server.Addr) 90 | if err := me.server.ListenAndServe(); err != ssh.ErrServerClosed { 91 | return err 92 | } 93 | return nil 94 | } 95 | 96 | // Shutdown gracefully shuts down the SSH server. 97 | func (me *SSHServer) Shutdown(ctx context.Context) error { 98 | log.Print("Stopping SSH server", "addr", me.server.Addr) 99 | return me.server.Shutdown(ctx) 100 | } 101 | 102 | func (me *SSHServer) sendAPIMessage(s ssh.Session, msg string) error { 103 | return me.sendJSON(s, charm.Message{Message: msg}) 104 | } 105 | 106 | func (me *SSHServer) sendJSON(s ssh.Session, o interface{}) error { 107 | return json.NewEncoder(s).Encode(o) 108 | } 109 | 110 | func (me *SSHServer) authHandler(_ ssh.Context, _ ssh.PublicKey) bool { 111 | return true 112 | } 113 | 114 | func (me *SSHServer) handleJWT(s ssh.Session) { 115 | var aud []string 116 | if cmd := s.Command(); len(cmd) > 1 { 117 | aud = cmd[1:] 118 | } else { 119 | aud = []string{"charm"} 120 | } 121 | key, err := keyText(s) 122 | if err != nil { 123 | log.Error(err) 124 | return 125 | } 126 | u, err := me.db.UserForKey(key, true) 127 | if err != nil { 128 | log.Error(err) 129 | return 130 | } 131 | log.Debug("JWT for user", "id", u.CharmID) 132 | j, err := me.newJWT(u.CharmID, aud...) 133 | if err != nil { 134 | log.Error(err) 135 | return 136 | } 137 | _, _ = s.Write([]byte(j)) 138 | me.config.Stats.JWT() 139 | } 140 | 141 | func (me *SSHServer) newJWT(charmID string, audience ...string) (string, error) { 142 | claims := &jwt.RegisteredClaims{ 143 | Subject: charmID, 144 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), 145 | Issuer: me.config.httpURL().String(), 146 | Audience: audience, 147 | } 148 | token := jwt.NewWithClaims(&jwt.SigningMethodEd25519{}, claims) 149 | token.Header["kid"] = me.config.jwtKeyPair.JWK.KeyID 150 | return token.SignedString(me.config.jwtKeyPair.PrivateKey) 151 | } 152 | 153 | // keyText is the base64 encoded public key for the glider.Session. 154 | func keyText(s ssh.Session) (string, error) { 155 | if s.PublicKey() == nil { 156 | return "", fmt.Errorf("Session doesn't have public key") 157 | } 158 | kb := base64.StdEncoding.EncodeToString(s.PublicKey().Marshal()) 159 | return fmt.Sprintf("%s %s", s.PublicKey().Type(), kb), nil 160 | } 161 | -------------------------------------------------------------------------------- /server/stats/noop/noop.go: -------------------------------------------------------------------------------- 1 | // Package noop provides a stats impl that does nothing. 2 | // nolint:revive 3 | package noop 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/charmbracelet/charm/server/stats" 9 | ) 10 | 11 | // Stats is a stats implementation that does nothing. 12 | type Stats struct{} 13 | 14 | var _ stats.Stats = Stats{} 15 | 16 | func (Stats) APILinkGen() {} 17 | func (Stats) APILinkRequest() {} 18 | func (Stats) APIUnlink() {} 19 | func (Stats) APIAuth() {} 20 | func (Stats) APIKeys() {} 21 | func (Stats) LinkGen() {} 22 | func (Stats) LinkRequest() {} 23 | func (Stats) Keys() {} 24 | func (Stats) ID() {} 25 | func (Stats) JWT() {} 26 | func (Stats) GetUserByID() {} 27 | func (Stats) GetUser() {} 28 | func (Stats) SetUserName() {} 29 | func (Stats) GetNewsList() {} 30 | func (Stats) GetNews() {} 31 | func (Stats) PostNews() {} 32 | func (Stats) FSFileRead(_ string, _ int64) {} 33 | func (Stats) FSFileWritten(_ string, _ int64) {} 34 | func (Stats) Start() error { return nil } 35 | func (Stats) Close() error { return nil } 36 | func (Stats) Shutdown(_ context.Context) error { return nil } 37 | -------------------------------------------------------------------------------- /server/stats/prometheus/prometheus.go: -------------------------------------------------------------------------------- 1 | package prometheus 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/charmbracelet/charm/server/db" 10 | "github.com/charmbracelet/log" 11 | "github.com/prometheus/client_golang/prometheus" 12 | "github.com/prometheus/client_golang/prometheus/promauto" 13 | "github.com/prometheus/client_golang/prometheus/promhttp" 14 | ) 15 | 16 | // Stats contains all of the calls to track metrics. 17 | type Stats struct { 18 | apiLinkGenCalls prometheus.Counter 19 | apiLinkRequestCalls prometheus.Counter 20 | apiUnlinkCalls prometheus.Counter 21 | apiAuthCalls prometheus.Counter 22 | apiKeysCalls prometheus.Counter 23 | linkGenCalls prometheus.Counter 24 | linkRequestCalls prometheus.Counter 25 | keysCalls prometheus.Counter 26 | idCalls prometheus.Counter 27 | jwtCalls prometheus.Counter 28 | getUserByIDCalls prometheus.Counter 29 | getUserCalls prometheus.Counter 30 | setUserNameCalls prometheus.Counter 31 | getNews prometheus.Counter 32 | postNews prometheus.Counter 33 | getNewsList prometheus.Counter 34 | fsBytesRead *prometheus.CounterVec 35 | fsBytesWritten *prometheus.CounterVec 36 | fsReads *prometheus.CounterVec 37 | fsWritten *prometheus.CounterVec 38 | users prometheus.Gauge 39 | userNames prometheus.Gauge 40 | db db.DB 41 | port int 42 | server *http.Server 43 | } 44 | 45 | // Start starts the PrometheusStats HTTP server. 46 | func (ps *Stats) Start() error { 47 | // collect totals every minute 48 | go func() { 49 | for { 50 | c, err := ps.db.UserCount() 51 | if err == nil { 52 | ps.users.Set(float64(c)) 53 | } 54 | c, err = ps.db.UserNameCount() 55 | if err == nil { 56 | ps.userNames.Set(float64(c)) 57 | } 58 | 59 | time.Sleep(time.Minute) 60 | } 61 | }() 62 | log.Print("Starting Stats HTTP server", "addr", ps.server.Addr) 63 | err := ps.server.ListenAndServe() 64 | if err != http.ErrServerClosed { 65 | return err 66 | } 67 | return nil 68 | } 69 | 70 | // Shutdown shuts down the Stats HTTP server. 71 | func (ps *Stats) Shutdown(ctx context.Context) error { 72 | return ps.server.Shutdown(ctx) 73 | } 74 | 75 | // Close immediately closes the Stats HTTP server. 76 | func (ps *Stats) Close() error { 77 | return ps.server.Close() 78 | } 79 | 80 | // NewStats returns a new Stats HTTP server configured to 81 | // the supplied port. 82 | func NewStats(db db.DB, port int) *Stats { 83 | mux := http.NewServeMux() 84 | mux.Handle("/metrics", promhttp.Handler()) 85 | s := &http.Server{ 86 | Addr: fmt.Sprintf(":%d", port), 87 | Handler: mux, 88 | ReadTimeout: 10 * time.Second, 89 | WriteTimeout: 10 * time.Second, 90 | MaxHeaderBytes: 1 << 20, 91 | } 92 | 93 | fsLabels := []string{"charm_id"} 94 | return &Stats{ 95 | apiLinkGenCalls: newCounter("charm_id_api_link_gen_total", "Total API link gen calls"), 96 | apiLinkRequestCalls: newCounter("charm_id_api_link_request_total", "Total api link request calls"), 97 | apiUnlinkCalls: newCounter("charm_id_api_unlink_total", "Total api unlink calls"), 98 | apiAuthCalls: newCounter("charm_id_api_auth_total", "Total api auth calls"), 99 | apiKeysCalls: newCounter("charm_id_api_keys_total", "Total api keys calls"), 100 | linkGenCalls: newCounter("charm_id_link_gen_total", "Total link gen calls"), 101 | linkRequestCalls: newCounter("charm_id_link_request_total", "Total link request calls"), 102 | keysCalls: newCounter("charm_id_keys_total", "Total keys calls"), 103 | idCalls: newCounter("charm_id_id_total", "Total id calls"), 104 | jwtCalls: newCounter("charm_id_jwt_total", "Total jwt calls"), 105 | getUserByIDCalls: newCounter("charm_bio_get_user_by_id_total", "Total bio user by id calls"), 106 | getUserCalls: newCounter("charm_bio_get_user_total", "Total bio get user calls"), 107 | setUserNameCalls: newCounter("charm_bio_set_username_total", "Total total bio set username calls"), 108 | getNews: newCounter("charm_news_get_news_total", "Total get news calls"), 109 | postNews: newCounter("charm_news_post_news_total", "Total post news calls"), 110 | getNewsList: newCounter("charm_news_get_news_list_total", "Total get news list calls"), 111 | fsBytesRead: newCounterWithLabels("charm_fs_bytes_read_total", "Total bytes read", fsLabels), 112 | fsBytesWritten: newCounterWithLabels("charm_fs_bytes_written_total", "Total bytes written", fsLabels), 113 | fsReads: newCounterWithLabels("charm_fs_files_read_total", "Total files read", fsLabels), 114 | fsWritten: newCounterWithLabels("charm_fs_files_written_total", "Total files read", fsLabels), 115 | users: newGauge("charm_bio_users", "Total users"), 116 | userNames: newGauge("charm_bio_users_names", "Total usernames"), 117 | db: db, 118 | port: port, 119 | server: s, 120 | } 121 | } 122 | 123 | // APILinkGen increments the number of api-link-gen calls. 124 | func (ps *Stats) APILinkGen() { 125 | ps.apiLinkGenCalls.Inc() 126 | } 127 | 128 | // APILinkRequest increments the number of api-link-request calls. 129 | func (ps *Stats) APILinkRequest() { 130 | ps.apiLinkRequestCalls.Inc() 131 | } 132 | 133 | // APIUnlink increments the number of api-unlink calls. 134 | func (ps *Stats) APIUnlink() { 135 | ps.apiUnlinkCalls.Inc() 136 | } 137 | 138 | // APIAuth increments the number of api-auth calls. 139 | func (ps *Stats) APIAuth() { 140 | ps.apiAuthCalls.Inc() 141 | } 142 | 143 | // APIKeys increments the number of api-keys calls. 144 | func (ps *Stats) APIKeys() { 145 | ps.apiKeysCalls.Inc() 146 | } 147 | 148 | // LinkGen increments the number of link-gen calls. 149 | func (ps *Stats) LinkGen() { 150 | ps.linkGenCalls.Inc() 151 | } 152 | 153 | // LinkRequest increments the number of link-request calls. 154 | func (ps *Stats) LinkRequest() { 155 | ps.linkRequestCalls.Inc() 156 | } 157 | 158 | // Keys increments the number of keys calls. 159 | func (ps *Stats) Keys() { 160 | ps.keysCalls.Inc() 161 | } 162 | 163 | // ID increments the number of id calls. 164 | func (ps *Stats) ID() { 165 | ps.idCalls.Inc() 166 | } 167 | 168 | // JWT increments the number of jwt calls. 169 | func (ps *Stats) JWT() { 170 | ps.jwtCalls.Inc() 171 | } 172 | 173 | // GetUserByID increments the number of user-by-id calls. 174 | func (ps *Stats) GetUserByID() { 175 | ps.getUserByIDCalls.Inc() 176 | } 177 | 178 | // GetUser increments the number of get-user calls. 179 | func (ps *Stats) GetUser() { 180 | ps.getUserCalls.Inc() 181 | } 182 | 183 | // SetUserName increments the number of set-user-name calls. 184 | func (ps *Stats) SetUserName() { 185 | ps.setUserNameCalls.Inc() 186 | } 187 | 188 | // GetNews increments the number of get-news calls. 189 | func (ps *Stats) GetNews() { 190 | ps.getNews.Inc() 191 | } 192 | 193 | // PostNews increments the number of post-news calls. 194 | func (ps *Stats) PostNews() { 195 | ps.postNews.Inc() 196 | } 197 | 198 | // GetNewsList increments the number of get-news-list calls. 199 | func (ps *Stats) GetNewsList() { 200 | ps.getNewsList.Inc() 201 | } 202 | 203 | // FSFileRead reports metrics on a read file by a given charm_id. 204 | func (ps *Stats) FSFileRead(id string, size int64) { 205 | ps.fsReads.WithLabelValues(id).Inc() 206 | ps.fsBytesRead.WithLabelValues(id).Add(float64(size)) 207 | } 208 | 209 | // FSFileWritten reports metrics on a written file by a given charm_id. 210 | func (ps *Stats) FSFileWritten(id string, size int64) { 211 | ps.fsWritten.WithLabelValues(id).Inc() 212 | ps.fsBytesWritten.WithLabelValues(id).Add(float64(size)) 213 | } 214 | 215 | func newCounter(name string, help string) prometheus.Counter { 216 | return promauto.NewCounter(prometheus.CounterOpts{ 217 | Name: name, 218 | Help: help, 219 | }) 220 | } 221 | 222 | func newCounterWithLabels(name string, help string, labels []string) *prometheus.CounterVec { 223 | return promauto.NewCounterVec(prometheus.CounterOpts{ 224 | Name: name, 225 | Help: help, 226 | }, labels) 227 | } 228 | 229 | func newGauge(name string, help string) prometheus.Gauge { 230 | return promauto.NewGauge(prometheus.GaugeOpts{ 231 | Name: name, 232 | Help: help, 233 | }) 234 | } 235 | -------------------------------------------------------------------------------- /server/stats/stats.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import "context" 4 | 5 | // Stats provides an interface that different stats backend can implement to 6 | // track server usage. 7 | type Stats interface { 8 | Start() error 9 | Shutdown(context.Context) error 10 | APILinkGen() 11 | APILinkRequest() 12 | APIUnlink() 13 | APIAuth() 14 | APIKeys() 15 | LinkGen() 16 | LinkRequest() 17 | Keys() 18 | ID() 19 | JWT() 20 | GetUserByID() 21 | GetUser() 22 | SetUserName() 23 | GetNewsList() 24 | GetNews() 25 | PostNews() 26 | FSFileRead(id string, size int64) 27 | FSFileWritten(id string, size int64) 28 | Close() error 29 | } 30 | -------------------------------------------------------------------------------- /server/storage/local/storage.go: -------------------------------------------------------------------------------- 1 | package localstorage 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "os" 10 | "path/filepath" 11 | 12 | charmfs "github.com/charmbracelet/charm/fs" 13 | charm "github.com/charmbracelet/charm/proto" 14 | "github.com/charmbracelet/charm/server/storage" 15 | ) 16 | 17 | // LocalFileStore is a FileStore implementation that stores files locally in a 18 | // folder. 19 | type LocalFileStore struct { 20 | Path string 21 | } 22 | 23 | // NewLocalFileStore creates a FileStore locally in the provided path. Files 24 | // will be encrypted client-side and stored as regular file system files and 25 | // folders. 26 | func NewLocalFileStore(path string) (*LocalFileStore, error) { 27 | err := storage.EnsureDir(path, 0o700) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &LocalFileStore{path}, nil 32 | } 33 | 34 | // Stat returns the FileInfo for the given Charm ID and path. 35 | func (lfs *LocalFileStore) Stat(charmID, path string) (fs.FileInfo, error) { 36 | fp := filepath.Join(lfs.Path, charmID, path) 37 | i, err := os.Stat(fp) 38 | if os.IsNotExist(err) { 39 | return nil, fs.ErrNotExist 40 | } 41 | if err != nil { 42 | return nil, err 43 | } 44 | in := &charmfs.FileInfo{ 45 | FileInfo: charm.FileInfo{ 46 | Name: i.Name(), 47 | IsDir: i.IsDir(), 48 | Size: i.Size(), 49 | ModTime: i.ModTime(), 50 | Mode: i.Mode(), 51 | }, 52 | } 53 | // Get the actual size of the files in a directory 54 | if i.IsDir() { 55 | in.FileInfo.Size = 0 56 | if err = filepath.Walk(fp, func(path string, info fs.FileInfo, err error) error { 57 | if info.IsDir() { 58 | return nil 59 | } 60 | in.FileInfo.Size += info.Size() 61 | return nil 62 | }); err != nil { 63 | return nil, err 64 | } 65 | } 66 | return in, nil 67 | } 68 | 69 | // Get returns an fs.File for the given Charm ID and path. 70 | func (lfs *LocalFileStore) Get(charmID string, path string) (fs.File, error) { 71 | fp := filepath.Join(lfs.Path, charmID, path) 72 | info, err := os.Stat(fp) 73 | if os.IsNotExist(err) { 74 | return nil, fs.ErrNotExist 75 | } 76 | if err != nil { 77 | return nil, err 78 | } 79 | f, err := os.Open(fp) 80 | if err != nil { 81 | return nil, err 82 | } 83 | // write a directory listing if path is a dir 84 | if info.IsDir() { 85 | rds, err := f.ReadDir(0) 86 | if err != nil { 87 | return nil, err 88 | } 89 | fis := make([]charm.FileInfo, 0) 90 | for _, v := range rds { 91 | fi, err := v.Info() 92 | if err != nil { 93 | return nil, err 94 | } 95 | fin := charm.FileInfo{ 96 | Name: v.Name(), 97 | IsDir: fi.IsDir(), 98 | Size: fi.Size(), 99 | ModTime: fi.ModTime(), 100 | Mode: fi.Mode(), 101 | } 102 | fis = append(fis, fin) 103 | } 104 | dir := charm.FileInfo{ 105 | Name: info.Name(), 106 | IsDir: true, 107 | Size: 0, 108 | ModTime: info.ModTime(), 109 | Mode: info.Mode(), 110 | Files: fis, 111 | } 112 | buf := bytes.NewBuffer(nil) 113 | enc := json.NewEncoder(buf) 114 | err = enc.Encode(dir) 115 | if err != nil { 116 | return nil, err 117 | } 118 | return &charmfs.DirFile{ 119 | Buffer: buf, 120 | FileInfo: info, 121 | }, nil 122 | } 123 | return f, nil 124 | } 125 | 126 | // Put reads from the provided io.Reader and stores the data with the Charm ID 127 | // and path. 128 | func (lfs *LocalFileStore) Put(charmID string, path string, r io.Reader, mode fs.FileMode) error { 129 | if cpath := filepath.Clean(path); cpath == string(os.PathSeparator) { 130 | return fmt.Errorf("invalid path specified: %s", cpath) 131 | } 132 | 133 | fp := filepath.Join(lfs.Path, charmID, path) 134 | if mode.IsDir() { 135 | return storage.EnsureDir(fp, mode) 136 | } 137 | err := storage.EnsureDir(filepath.Dir(fp), mode) 138 | if err != nil { 139 | return err 140 | } 141 | f, err := os.Create(fp) 142 | if err != nil { 143 | return err 144 | } 145 | defer f.Close() // nolint:errcheck 146 | _, err = io.Copy(f, r) 147 | if err != nil { 148 | return err 149 | } 150 | if mode != 0 { 151 | return f.Chmod(mode) 152 | } 153 | return nil 154 | } 155 | 156 | // Delete deletes the file at the given path for the provided Charm ID. 157 | func (lfs *LocalFileStore) Delete(charmID string, path string) error { 158 | fp := filepath.Join(lfs.Path, charmID, path) 159 | return os.RemoveAll(fp) 160 | } 161 | -------------------------------------------------------------------------------- /server/storage/local/storage_test.go: -------------------------------------------------------------------------------- 1 | package localstorage 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/google/uuid" 12 | ) 13 | 14 | func TestPut(t *testing.T) { 15 | tdir := t.TempDir() 16 | charmID := uuid.New().String() 17 | buf := bytes.NewBufferString("") 18 | lfs, err := NewLocalFileStore(tdir) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | 23 | paths := []string{filepath.Join(string(os.PathSeparator), ""), filepath.Join(string(os.PathSeparator), "//")} 24 | for _, path := range paths { 25 | err = lfs.Put(charmID, path, buf, fs.FileMode(0o644)) 26 | if err == nil { 27 | t.Fatalf("expected error when file path is %s", path) 28 | } 29 | 30 | } 31 | 32 | content := "hello world" 33 | path := filepath.Join(string(os.PathSeparator), "hello.txt") 34 | t.Run(path, func(t *testing.T) { 35 | buf = bytes.NewBufferString(content) 36 | err = lfs.Put(charmID, path, buf, fs.FileMode(0o644)) 37 | if err != nil { 38 | t.Fatalf("expected no error when file path is %s, %v", path, err) 39 | } 40 | 41 | file, err := os.Open(filepath.Join(tdir, charmID, path)) 42 | if err != nil { 43 | t.Fatalf("expected no error when opening file %s", path) 44 | } 45 | defer file.Close() //nolint:errcheck 46 | 47 | fileInfo, err := file.Stat() 48 | if err != nil { 49 | t.Fatalf("expected no error when getting file info for %s", path) 50 | } 51 | 52 | if fileInfo.IsDir() { 53 | t.Fatalf("expected file %s to be a regular file", path) 54 | } 55 | 56 | read, err := io.ReadAll(file) 57 | if err != nil { 58 | t.Fatalf("expected no error when reading file %s", path) 59 | } 60 | if string(read) != content { 61 | t.Fatalf("expected content to be %s, got %s", content, string(read)) 62 | } 63 | }) 64 | 65 | content = "bar" 66 | path = filepath.Join(string(os.PathSeparator), "foo", "hello.txt") 67 | t.Run(path, func(t *testing.T) { 68 | buf = bytes.NewBufferString(content) 69 | err = lfs.Put(charmID, path, buf, fs.FileMode(0o644)) 70 | if err != nil { 71 | t.Fatalf("expected no error when file path is %s, %v", path, err) 72 | } 73 | 74 | file, err := os.Open(filepath.Join(tdir, charmID, path)) 75 | if err != nil { 76 | t.Fatalf("expected no error when opening file %s", path) 77 | } 78 | defer file.Close() //nolint:errcheck 79 | 80 | fileInfo, err := file.Stat() 81 | if err != nil { 82 | t.Fatalf("expected no error when getting file info for %s", path) 83 | } 84 | 85 | if fileInfo.IsDir() { 86 | t.Fatalf("expected file %s to be a regular file", path) 87 | } 88 | 89 | read, err := io.ReadAll(file) 90 | if err != nil { 91 | t.Fatalf("expected no error when reading file %s", path) 92 | } 93 | if string(read) != content { 94 | t.Fatalf("expected content to be %s, got %s", content, string(read)) 95 | } 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /server/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "io" 5 | "io/fs" 6 | "os" 7 | ) 8 | 9 | // FileStore is the interface storage backends need to implement to act as a 10 | // the datastore for the Charm Cloud server. 11 | type FileStore interface { 12 | Stat(charmID string, path string) (fs.FileInfo, error) 13 | Get(charmID string, path string) (fs.File, error) 14 | Put(charmID string, path string, r io.Reader, mode fs.FileMode) error 15 | Delete(charmID string, path string) error 16 | } 17 | 18 | // EnsureDir will create the directory for the provided path on the server 19 | // operating system. New directories will have the execute mode set for any 20 | // level of read permission if execute isn't provided in the fs.FileMode. 21 | func EnsureDir(path string, mode fs.FileMode) error { 22 | _, err := os.Stat(path) 23 | dp := addExecPermsForMkDir(mode.Perm()) 24 | if os.IsNotExist(err) { 25 | return os.MkdirAll(path, dp) 26 | } 27 | return err 28 | } 29 | 30 | func addExecPermsForMkDir(mode fs.FileMode) fs.FileMode { 31 | if mode.IsDir() { 32 | return mode 33 | } 34 | op := mode.Perm() 35 | if op&0400 == 0400 { 36 | op = op | 0100 37 | } 38 | if op&0040 == 0040 { 39 | op = op | 0010 40 | } 41 | if op&0004 == 0004 { 42 | op = op | 0001 43 | } 44 | return mode | op | fs.ModeDir 45 | } 46 | -------------------------------------------------------------------------------- /systemd.md: -------------------------------------------------------------------------------- 1 | # Running Charm with Systemd 2 | 3 | Running `charm` as a systemd service is fairly straightforward. Create a file 4 | called `/etc/systemd/system/charm.service`: 5 | 6 | ```config 7 | [Unit] 8 | Description=The mystical Charm Cloud 🌟 9 | After=network.target 10 | StartLimitIntervalSec=0 11 | 12 | [Service] 13 | Type=simple 14 | Restart=always 15 | RestartSec=1 16 | Environment=CHARM_SERVER_DATA_DIR=/var/lib/charm 17 | ExecStart=/usr/bin/charm serve 18 | 19 | [Install] 20 | WantedBy=multi-user.target 21 | ``` 22 | 23 | * Set the proper `charm` binary path in `ExecStart=` 24 | * Set where the data should be stored at in `CHARM_SERVER_DATA_DIR` 25 | 26 | If you’re using TLS, don’t forget to set the appropriate environment variables 27 | in the systemd service file as described below. 28 | 29 | ## TLS 30 | 31 | See [TLS](README.md#tls) for more information. 32 | 33 | *** 34 | 35 | Part of [Charm](https://charm.sh). 36 | 37 | the Charm logo 38 | 39 | Charm热爱开源 • Charm loves open source 40 | 41 | -------------------------------------------------------------------------------- /testserver/testserver.go: -------------------------------------------------------------------------------- 1 | package testserver 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/charmbracelet/charm/client" 15 | "github.com/charmbracelet/charm/server" 16 | "github.com/charmbracelet/keygen" 17 | ) 18 | 19 | // SetupTestServer starts a test server and sets the needed environment 20 | // variables so clients pick it up. 21 | // It also returns a client forcing these settings in. 22 | // Unless you use the given client, this is not really thread safe due 23 | // to setting a bunch of environment variables. 24 | func SetupTestServer(tb testing.TB) *client.Client { 25 | tb.Helper() 26 | 27 | td := tb.TempDir() 28 | sp := filepath.Join(td, ".ssh") 29 | clientData := filepath.Join(td, ".client-data") 30 | 31 | cfg := server.DefaultConfig() 32 | cfg.DataDir = filepath.Join(td, ".data") 33 | cfg.SSHPort = randomPort(tb) 34 | cfg.HTTPPort = randomPort(tb) 35 | cfg.HealthPort = randomPort(tb) 36 | 37 | kp, err := keygen.New(filepath.Join(sp, "charm_server_ed25519"), keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite()) 38 | if err != nil { 39 | tb.Fatalf("keygen error: %s", err) 40 | } 41 | 42 | // TODO: see if this works the same 43 | cfg = cfg.WithKeys(kp.RawAuthorizedKey(), kp.RawPrivateKey()) 44 | s, err := server.NewServer(cfg) 45 | if err != nil { 46 | tb.Fatalf("new server error: %s", err) 47 | } 48 | 49 | _ = os.Setenv("CHARM_HOST", cfg.Host) 50 | _ = os.Setenv("CHARM_SSH_PORT", fmt.Sprintf("%d", cfg.SSHPort)) 51 | _ = os.Setenv("CHARM_HTTP_PORT", fmt.Sprintf("%d", cfg.HTTPPort)) 52 | _ = os.Setenv("CHARM_DATA_DIR", clientData) 53 | 54 | go func() { _ = s.Start() }() 55 | 56 | resp, err := FetchURL(fmt.Sprintf("http://localhost:%d", cfg.HealthPort), 3) 57 | if err != nil { 58 | tb.Fatalf("server likely failed to start: %s", err) 59 | } 60 | defer resp.Body.Close() // nolint:errcheck 61 | 62 | tb.Cleanup(func() { 63 | if err := s.Close(); err != nil { 64 | tb.Error("failed to close server:", err) 65 | } 66 | 67 | _ = os.Unsetenv("CHARM_HOST") 68 | _ = os.Unsetenv("CHARM_SSH_PORT") 69 | _ = os.Unsetenv("CHARM_HTTP_PORT") 70 | _ = os.Unsetenv("CHARM_DATA_DIR") 71 | }) 72 | 73 | ccfg, err := client.ConfigFromEnv() 74 | if err != nil { 75 | tb.Fatalf("client config from env error: %s", err) 76 | } 77 | 78 | ccfg.Host = cfg.Host 79 | ccfg.SSHPort = cfg.SSHPort 80 | ccfg.HTTPPort = cfg.HTTPPort 81 | ccfg.DataDir = clientData 82 | 83 | cl, err := client.NewClient(ccfg) 84 | if err != nil { 85 | tb.Fatalf("new client error: %s", err) 86 | } 87 | return cl 88 | } 89 | 90 | // Fetch the given URL with N retries. 91 | func FetchURL(url string, retries int) (*http.Response, error) { 92 | resp, err := http.Get(url) // nolint:gosec 93 | if err != nil { 94 | if retries > 0 { 95 | time.Sleep(time.Second) 96 | return FetchURL(url, retries-1) 97 | } 98 | return nil, err 99 | } 100 | if resp.StatusCode != 200 { 101 | return resp, fmt.Errorf("bad http status code: %d", resp.StatusCode) 102 | } 103 | return resp, nil 104 | } 105 | 106 | func randomPort(tb testing.TB) int { 107 | listener, err := net.Listen("tcp", "127.0.0.1:0") 108 | if err != nil { 109 | tb.Fatalf("could not get a random port: %s", err) 110 | } 111 | listener.Close() //nolint:errcheck 112 | 113 | addr := listener.Addr().String() 114 | 115 | p, _ := strconv.Atoi(addr[strings.LastIndex(addr, ":")+1:]) 116 | return p 117 | } 118 | -------------------------------------------------------------------------------- /ui/charmclient/client.go: -------------------------------------------------------------------------------- 1 | package charmclient 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/charm/client" 6 | charm "github.com/charmbracelet/charm/proto" 7 | ) 8 | 9 | // NewClientMsg is sent when we've successfully created a charm client. 10 | type NewClientMsg *client.Client 11 | 12 | // SSHAuthErrorMsg is sent when charm client creation has failed due to a 13 | // problem with SSH. 14 | type SSHAuthErrorMsg struct { 15 | Err error 16 | } 17 | 18 | // ErrMsg is sent for general, non-SSH related errors encountered when creating 19 | // a Charm Client. 20 | type ErrMsg struct { 21 | Err error 22 | } 23 | 24 | // NewClient is a Bubble Tea command for creating a Charm client. 25 | func NewClient(cfg *client.Config) tea.Cmd { 26 | return func() tea.Msg { 27 | cc, err := client.NewClient(cfg) 28 | 29 | if err == charm.ErrMissingSSHAuth { 30 | return SSHAuthErrorMsg{err} 31 | } else if err != nil { 32 | return ErrMsg{err} 33 | } 34 | 35 | return NewClientMsg(cc) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ui/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "os" 5 | "sync" 6 | 7 | isatty "github.com/mattn/go-isatty" 8 | ) 9 | 10 | var ( 11 | isTTY bool 12 | checkTTY sync.Once 13 | ) 14 | 15 | // Returns true if standard out is a terminal. 16 | func IsTTY() bool { 17 | checkTTY.Do(func() { 18 | isTTY = isatty.IsTerminal(os.Stdout.Fd()) 19 | }) 20 | return isTTY 21 | } 22 | -------------------------------------------------------------------------------- /ui/common/styles.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | // Color definitions. 8 | var ( 9 | indigo = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"} 10 | subtleIndigo = lipgloss.AdaptiveColor{Light: "#7D79F6", Dark: "#514DC1"} 11 | cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"} 12 | fuschia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"} 13 | green = lipgloss.Color("#04B575") 14 | red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"} 15 | faintRed = lipgloss.AdaptiveColor{Light: "#FF6F91", Dark: "#C74665"} 16 | ) 17 | 18 | // Styles describes style definitions for various portions of the Charm TUI. 19 | type Styles struct { 20 | Cursor, 21 | Wrap, 22 | Paragraph, 23 | Keyword, 24 | Code, 25 | Subtle, 26 | Error, 27 | Prompt, 28 | FocusedPrompt, 29 | Note, 30 | NoteDim, 31 | Delete, 32 | DeleteDim, 33 | Label, 34 | LabelDim, 35 | ListKey, 36 | ListDim, 37 | InactivePagination, 38 | SelectionMarker, 39 | SelectedMenuItem, 40 | Checkmark, 41 | Logo, 42 | App lipgloss.Style 43 | } 44 | 45 | // DefaultStyles returns default styles for the Charm TUI. 46 | func DefaultStyles() Styles { 47 | s := Styles{} 48 | 49 | s.Cursor = lipgloss.NewStyle().Foreground(fuschia) 50 | s.Wrap = lipgloss.NewStyle().Width(58) 51 | s.Keyword = lipgloss.NewStyle().Foreground(green) 52 | s.Paragraph = s.Wrap.Copy().Margin(1, 0, 0, 2) 53 | s.Code = lipgloss.NewStyle(). 54 | Foreground(lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}). 55 | Background(lipgloss.AdaptiveColor{Light: "#EBE5EC", Dark: "#2B2A2A"}). 56 | Padding(0, 1) 57 | s.Subtle = lipgloss.NewStyle(). 58 | Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}) 59 | s.Error = lipgloss.NewStyle().Foreground(red) 60 | s.Prompt = lipgloss.NewStyle().MarginRight(1).SetString(">") 61 | s.FocusedPrompt = s.Prompt.Copy().Foreground(fuschia) 62 | s.Note = lipgloss.NewStyle().Foreground(green) 63 | s.NoteDim = lipgloss.NewStyle(). 64 | Foreground(lipgloss.AdaptiveColor{Light: "#ABE5D1", Dark: "#2B4A3F"}) 65 | s.Delete = s.Error.Copy() 66 | s.DeleteDim = lipgloss.NewStyle().Foreground(faintRed) 67 | s.Label = lipgloss.NewStyle().Foreground(fuschia) 68 | s.LabelDim = lipgloss.NewStyle().Foreground(indigo) 69 | s.ListKey = lipgloss.NewStyle().Foreground(indigo) 70 | s.ListDim = lipgloss.NewStyle().Foreground(subtleIndigo) 71 | s.InactivePagination = lipgloss.NewStyle(). 72 | Foreground(lipgloss.AdaptiveColor{Light: "#CACACA", Dark: "#4F4F4F"}) 73 | s.SelectionMarker = lipgloss.NewStyle(). 74 | Foreground(fuschia). 75 | PaddingRight(1). 76 | SetString(">") 77 | s.Checkmark = lipgloss.NewStyle(). 78 | SetString("✔"). 79 | Foreground(green) 80 | s.SelectedMenuItem = lipgloss.NewStyle().Foreground(fuschia) 81 | s.Logo = lipgloss.NewStyle(). 82 | Foreground(cream). 83 | Background(lipgloss.Color("#5A56E0")). 84 | Padding(0, 1). 85 | SetString("Charm") 86 | s.App = lipgloss.NewStyle().Margin(1, 0, 1, 2) 87 | 88 | return s 89 | } 90 | -------------------------------------------------------------------------------- /ui/common/views.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/spinner" 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | // State is a general UI state used to help style components. 12 | type State int 13 | 14 | // UI states. 15 | const ( 16 | StateNormal State = iota 17 | StateSelected 18 | StateActive 19 | StateSpecial 20 | StateDeleting 21 | ) 22 | 23 | var lineColors = map[State]lipgloss.TerminalColor{ 24 | StateNormal: lipgloss.AdaptiveColor{Light: "#BCBCBC", Dark: "#646464"}, 25 | StateSelected: lipgloss.Color("#F684FF"), 26 | StateDeleting: lipgloss.AdaptiveColor{Light: "#FF8BA7", Dark: "#893D4E"}, 27 | StateSpecial: lipgloss.Color("#04B575"), 28 | } 29 | 30 | // VerticalLine return a vertical line colored according to the given state. 31 | func VerticalLine(state State) string { 32 | return lipgloss.NewStyle(). 33 | SetString("│"). 34 | Foreground(lineColors[state]). 35 | String() 36 | } 37 | 38 | var valStyle = lipgloss.NewStyle().Foreground(indigo) 39 | 40 | var ( 41 | spinnerStyle = lipgloss.NewStyle(). 42 | Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"}) 43 | 44 | blurredButtonStyle = lipgloss.NewStyle(). 45 | Foreground(cream). 46 | Background(lipgloss.AdaptiveColor{Light: "#BDB0BE", Dark: "#827983"}). 47 | Padding(0, 3) 48 | 49 | focusedButtonStyle = blurredButtonStyle.Copy(). 50 | Background(fuschia) 51 | ) 52 | 53 | // NewSpinner returns a spinner model. 54 | func NewSpinner() spinner.Model { 55 | s := spinner.New() 56 | s.Spinner = spinner.Dot 57 | s.Style = spinnerStyle 58 | return s 59 | } 60 | 61 | // KeyValueView renders key-value pairs. 62 | func KeyValueView(stuff ...string) string { 63 | if len(stuff) == 0 { 64 | return "" 65 | } 66 | 67 | var ( 68 | s string 69 | index int 70 | ) 71 | for i := 0; i < len(stuff); i++ { 72 | if i%2 == 0 { 73 | // even: key 74 | s += fmt.Sprintf("%s %s: ", VerticalLine(StateNormal), stuff[i]) 75 | continue 76 | } 77 | // odd: value 78 | s += valStyle.Render(stuff[i]) 79 | s += "\n" 80 | index++ 81 | } 82 | 83 | return strings.TrimSpace(s) 84 | } 85 | 86 | var ( 87 | helpDivider = lipgloss.NewStyle(). 88 | Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}). 89 | Padding(0, 1). 90 | Render("•") 91 | 92 | helpSection = lipgloss.NewStyle(). 93 | Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}) 94 | ) 95 | 96 | // HelpView renders text intended to display at help text, often at the 97 | // bottom of a view. 98 | func HelpView(sections ...string) string { 99 | var s string 100 | if len(sections) == 0 { 101 | return s 102 | } 103 | 104 | for i := 0; i < len(sections); i++ { 105 | s += helpSection.Render(sections[i]) 106 | if i < len(sections)-1 { 107 | s += helpDivider 108 | } 109 | } 110 | 111 | return s 112 | } 113 | 114 | // ButtonView renders something that resembles a button. 115 | func ButtonView(text string, focused bool) string { 116 | return styledButton(text, false, focused) 117 | } 118 | 119 | // YesButtonView return a button reading "Yes". 120 | func YesButtonView(focused bool) string { 121 | var st lipgloss.Style 122 | if focused { 123 | st = focusedButtonStyle 124 | } else { 125 | st = blurredButtonStyle 126 | } 127 | return underlineInitialCharButton("Yes", st) 128 | } 129 | 130 | // NoButtonView returns a button reading "No.". 131 | func NoButtonView(focused bool) string { 132 | var st lipgloss.Style 133 | if focused { 134 | st = focusedButtonStyle 135 | } else { 136 | st = blurredButtonStyle 137 | } 138 | st = st.Copy(). 139 | PaddingLeft(st.GetPaddingLeft() + 1). 140 | PaddingRight(st.GetPaddingRight() + 1) 141 | return underlineInitialCharButton("No", st) 142 | } 143 | 144 | func underlineInitialCharButton(str string, style lipgloss.Style) string { 145 | if len(str) == 0 { 146 | return "" 147 | } 148 | 149 | var ( 150 | r = []rune(str) 151 | left = r[0] 152 | right = r[1:] 153 | ) 154 | 155 | leftStyle := style.Copy().Underline(true).UnsetPaddingRight() 156 | rightStyle := style.Copy().UnsetPaddingLeft() 157 | 158 | return leftStyle.Render(string(left)) + rightStyle.Render(string(right)) 159 | } 160 | 161 | // OKButtonView returns a button reading "OK". 162 | func OKButtonView(focused bool, defaultButton bool) string { 163 | return styledButton("OK", defaultButton, focused) 164 | } 165 | 166 | // CancelButtonView returns a button reading "Cancel.". 167 | func CancelButtonView(focused bool, defaultButton bool) string { 168 | return styledButton("Cancel", defaultButton, focused) 169 | } 170 | 171 | func styledButton(str string, underlined, focused bool) string { 172 | var st lipgloss.Style 173 | if focused { 174 | st = focusedButtonStyle.Copy() 175 | } else { 176 | st = blurredButtonStyle.Copy() 177 | } 178 | if underlined { 179 | st = st.Underline(true) 180 | } 181 | return st.Render(str) 182 | } 183 | -------------------------------------------------------------------------------- /ui/info/info.go: -------------------------------------------------------------------------------- 1 | package info 2 | 3 | // Fetch a user's basic Charm account info 4 | 5 | import ( 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/charm/client" 8 | charm "github.com/charmbracelet/charm/proto" 9 | "github.com/charmbracelet/charm/ui/common" 10 | ) 11 | 12 | // GotBioMsg is sent when we've successfully fetched the user's bio. It 13 | // contains the user's profile data. 14 | type GotBioMsg *charm.User 15 | 16 | type errMsg struct { 17 | err error 18 | } 19 | 20 | // Error satisfies the error interface. 21 | func (e errMsg) Error() string { 22 | return e.err.Error() 23 | } 24 | 25 | // Model stores the state of the info user interface. 26 | type Model struct { 27 | Quit bool // signals it's time to exit the whole application 28 | Err error 29 | User *charm.User 30 | cc *client.Client 31 | styles common.Styles 32 | } 33 | 34 | // NewModel returns a new Model in its initial state. 35 | func NewModel(cc *client.Client) Model { 36 | return Model{ 37 | Quit: false, 38 | User: nil, 39 | cc: cc, 40 | styles: common.DefaultStyles(), 41 | } 42 | } 43 | 44 | // Update is the Bubble Tea update loop. 45 | func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { 46 | var cmd tea.Cmd 47 | 48 | switch msg := msg.(type) { 49 | case tea.KeyMsg: 50 | switch msg.String() { 51 | case "ctrl+c", "esc", "q": 52 | m.Quit = true 53 | return m, nil 54 | } 55 | case GotBioMsg: 56 | m.User = msg 57 | case errMsg: 58 | // If there's an error we print the error and exit 59 | m.Err = msg 60 | m.Quit = true 61 | return m, nil 62 | } 63 | 64 | return m, cmd 65 | } 66 | 67 | // View renders the current view from the model. 68 | func (m Model) View() string { 69 | if m.Err != nil { 70 | return "error: " + m.Err.Error() 71 | } else if m.User == nil { 72 | return " Authenticating..." 73 | } 74 | return m.bioView() 75 | } 76 | 77 | func (m Model) bioView() string { 78 | var username string 79 | if m.User.Name != "" { 80 | username = m.User.Name 81 | } else { 82 | username = m.styles.Subtle.Render("(none set)") 83 | } 84 | return common.KeyValueView( 85 | "Host", m.cc.Config.Host, 86 | "Username", username, 87 | "Joined", m.User.CreatedAt.Format("02 Jan 2006"), 88 | ) 89 | } 90 | 91 | // GetBio fetches the authenticated user's bio. 92 | func GetBio(cc *client.Client) tea.Cmd { 93 | return func() tea.Msg { 94 | user, err := cc.Bio() 95 | if err != nil { 96 | return errMsg{err} 97 | } 98 | 99 | return GotBioMsg(user) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /ui/keys/keyview.go: -------------------------------------------------------------------------------- 1 | package keys 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/charm/client" 8 | charm "github.com/charmbracelet/charm/proto" 9 | "github.com/charmbracelet/charm/ui/common" 10 | ) 11 | 12 | // wrap fingerprint to support additional states. 13 | type fingerprint struct { 14 | client.Fingerprint 15 | } 16 | 17 | func (f fingerprint) state(s keyState, styles common.Styles) string { 18 | if s == keyDeleting { 19 | return fmt.Sprintf( 20 | "%s %s", 21 | styles.DeleteDim.Render(strings.ToUpper(f.Algorithm)), 22 | styles.Delete.Render(f.Type+":"+f.Value), 23 | ) 24 | } 25 | return f.String() 26 | } 27 | 28 | type styledKey struct { 29 | styles common.Styles 30 | date string 31 | fingerprint fingerprint 32 | gutter string 33 | keyLabel string 34 | dateLabel string 35 | dateVal string 36 | note string 37 | } 38 | 39 | func (m Model) newStyledKey(styles common.Styles, key charm.PublicKey, active bool) styledKey { 40 | date := key.CreatedAt.Format("02 Jan 2006 15:04:05 MST") 41 | fp, err := client.FingerprintSHA256(key) 42 | if err != nil { 43 | fp = client.Fingerprint{Value: "[error generating fingerprint]"} 44 | } 45 | 46 | var note string 47 | if active { 48 | note = m.styles.NoteDim.Render("• ") + m.styles.Note.Render("Current Key") 49 | } 50 | 51 | // Default state 52 | return styledKey{ 53 | styles: styles, 54 | date: date, 55 | fingerprint: fingerprint{fp}, 56 | gutter: " ", 57 | keyLabel: "Key:", 58 | dateLabel: "Added:", 59 | dateVal: styles.LabelDim.Render(date), 60 | note: note, 61 | } 62 | } 63 | 64 | // Selected state. 65 | func (k *styledKey) selected() { 66 | k.gutter = common.VerticalLine(common.StateSelected) 67 | k.keyLabel = k.styles.Label.Render("Key:") 68 | k.dateLabel = k.styles.Label.Render("Added:") 69 | } 70 | 71 | // Deleting state. 72 | func (k *styledKey) deleting() { 73 | k.gutter = common.VerticalLine(common.StateDeleting) 74 | k.keyLabel = k.styles.Delete.Render("Key:") 75 | k.dateLabel = k.styles.Delete.Render("Added:") 76 | k.dateVal = k.styles.DeleteDim.Render(k.date) 77 | } 78 | 79 | func (k styledKey) render(state keyState) string { 80 | switch state { 81 | case keySelected: 82 | k.selected() 83 | case keyDeleting: 84 | k.deleting() 85 | } 86 | return fmt.Sprintf( 87 | "%s %s %s\n%s %s %s %s\n\n", 88 | k.gutter, k.keyLabel, k.fingerprint.state(state, k.styles), 89 | k.gutter, k.dateLabel, k.dateVal, k.note, 90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /ui/link/link.go: -------------------------------------------------------------------------------- 1 | package link 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/spinner" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/charm/client" 9 | "github.com/charmbracelet/charm/ui/charmclient" 10 | "github.com/charmbracelet/charm/ui/common" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | var viewStyle = lipgloss.NewStyle().Padding(1, 2, 2, 3) 15 | 16 | // NewProgram returns a Tea program for the link participant. 17 | func NewProgram(cfg *client.Config, code string) *tea.Program { 18 | return tea.NewProgram(newModel(cfg, code)) 19 | } 20 | 21 | type status int 22 | 23 | const ( 24 | initCharmClient status = iota 25 | linkInit 26 | linkTokenSent 27 | linkTokenValid 28 | linkTokenInvalid 29 | linkRequestDenied 30 | linkSuccess 31 | linkTimeout 32 | linkErr 33 | quitting 34 | ) 35 | 36 | type ( 37 | tokenSentMsg struct{} 38 | validTokenMsg bool 39 | requestDeniedMsg struct{} 40 | successMsg bool 41 | timeoutMsg struct{} 42 | errMsg struct{ err error } 43 | ) 44 | 45 | type model struct { 46 | lh *linkHandler 47 | cfg *client.Config 48 | cc *client.Client 49 | styles common.Styles 50 | code string 51 | status status 52 | alreadyLinked bool 53 | err error 54 | spinner spinner.Model 55 | } 56 | 57 | func newModel(cfg *client.Config, code string) model { 58 | return model{ 59 | lh: newLinkHandler(), 60 | cfg: cfg, 61 | styles: common.DefaultStyles(), 62 | code: code, 63 | status: initCharmClient, 64 | alreadyLinked: false, 65 | err: nil, 66 | spinner: common.NewSpinner(), 67 | } 68 | } 69 | 70 | func (m model) Init() tea.Cmd { 71 | return tea.Batch( 72 | charmclient.NewClient(m.cfg), 73 | m.spinner.Tick, 74 | ) 75 | } 76 | 77 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 78 | switch msg := msg.(type) { 79 | case tea.KeyMsg: 80 | switch msg.String() { 81 | case "ctrl+c", "esc", "q": 82 | m.status = quitting 83 | return m, tea.Quit 84 | default: 85 | return m, nil 86 | } 87 | 88 | case charmclient.NewClientMsg: 89 | m.cc = msg 90 | m.status = linkInit 91 | return m, handleLinkRequest(m) 92 | 93 | case charmclient.ErrMsg: 94 | m.err = msg.Err 95 | return m, tea.Quit 96 | 97 | case tokenSentMsg: 98 | m.status = linkTokenSent 99 | return m, nil 100 | 101 | case validTokenMsg: 102 | if msg { 103 | m.status = linkTokenValid 104 | return m, nil 105 | } 106 | m.status = linkTokenInvalid 107 | return m, tea.Quit 108 | 109 | case requestDeniedMsg: 110 | m.status = linkRequestDenied 111 | return m, tea.Quit 112 | 113 | case successMsg: 114 | m.status = linkSuccess 115 | if msg { 116 | m.alreadyLinked = true 117 | } 118 | return m, tea.Quit 119 | 120 | case timeoutMsg: 121 | m.status = linkTimeout 122 | return m, tea.Quit 123 | 124 | case errMsg: 125 | m.status = linkErr 126 | return m, tea.Quit 127 | 128 | case spinner.TickMsg: 129 | var cmd tea.Cmd 130 | m.spinner, cmd = m.spinner.Update(msg) 131 | return m, cmd 132 | 133 | default: 134 | return m, nil 135 | } 136 | } 137 | 138 | func (m model) View() string { 139 | if m.err != nil { 140 | return viewStyle.Render(m.err.Error()) 141 | } 142 | 143 | s := m.spinner.View() + " " 144 | 145 | switch m.status { 146 | case initCharmClient: 147 | s += "Initializing..." 148 | case linkInit: 149 | s += "Linking..." 150 | case linkTokenSent: 151 | s += fmt.Sprintf("Token %s. Waiting for validation...", m.styles.Keyword.Render("sent")) 152 | case linkTokenValid: 153 | s += fmt.Sprintf("Token %s. Waiting for authorization...", m.styles.Keyword.Render("valid")) 154 | case linkTokenInvalid: 155 | s = fmt.Sprintf("%s token. Goodbye.", m.styles.Keyword.Render("Invalid")) 156 | case linkRequestDenied: 157 | s = fmt.Sprintf("Link request %s. Sorry, kid.", m.styles.Keyword.Render("denied")) 158 | case linkSuccess: 159 | s = m.styles.Keyword.Render("Linked!") 160 | if m.alreadyLinked { 161 | s += " You already linked this key, btw." 162 | } 163 | case linkTimeout: 164 | s = fmt.Sprintf("Link request %s. Sorry.", m.styles.Keyword.Render("timed out")) 165 | case linkErr: 166 | s = m.styles.Keyword.Render("Error.") 167 | case quitting: 168 | s = "Oh, ok. Bye." 169 | } 170 | 171 | return viewStyle.Render(s) 172 | } 173 | 174 | func handleLinkRequest(m model) tea.Cmd { 175 | go func() { 176 | if err := m.cc.Link(m.lh, m.code); err != nil { 177 | m.lh.err <- err 178 | } 179 | }() 180 | 181 | return tea.Batch( 182 | handleTokenSent(m.lh), 183 | handleValidToken(m.lh), 184 | handleRequestDenied(m.lh), 185 | handleLinkSuccess(m.lh), 186 | handleTimeout(m.lh), 187 | handleErr(m.lh), 188 | ) 189 | } 190 | 191 | func handleTokenSent(lh *linkHandler) tea.Cmd { 192 | return func() tea.Msg { 193 | <-lh.tokenSent 194 | return tokenSentMsg{} 195 | } 196 | } 197 | 198 | func handleValidToken(lh *linkHandler) tea.Cmd { 199 | return func() tea.Msg { 200 | return validTokenMsg(<-lh.validToken) 201 | } 202 | } 203 | 204 | func handleRequestDenied(lh *linkHandler) tea.Cmd { 205 | return func() tea.Msg { 206 | <-lh.requestDenied 207 | return requestDeniedMsg{} 208 | } 209 | } 210 | 211 | func handleLinkSuccess(lh *linkHandler) tea.Cmd { 212 | return func() tea.Msg { 213 | return successMsg(<-lh.success) 214 | } 215 | } 216 | 217 | func handleTimeout(lh *linkHandler) tea.Cmd { 218 | return func() tea.Msg { 219 | <-lh.timeout 220 | return timeoutMsg{} 221 | } 222 | } 223 | 224 | func handleErr(lh *linkHandler) tea.Cmd { 225 | return func() tea.Msg { 226 | return errMsg{<-lh.err} 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /ui/link/linkhandler.go: -------------------------------------------------------------------------------- 1 | package link 2 | 3 | import ( 4 | "errors" 5 | 6 | charm "github.com/charmbracelet/charm/proto" 7 | ) 8 | 9 | type linkHandler struct { 10 | tokenSent chan struct{} 11 | validToken chan bool 12 | success chan bool // true if the key was already linked 13 | requestDenied chan struct{} 14 | timeout chan struct{} 15 | err chan error 16 | } 17 | 18 | func newLinkHandler() *linkHandler { 19 | return &linkHandler{ 20 | tokenSent: make(chan struct{}), 21 | validToken: make(chan bool), 22 | success: make(chan bool), 23 | requestDenied: make(chan struct{}), 24 | timeout: make(chan struct{}), 25 | err: make(chan error), 26 | } 27 | } 28 | 29 | func (lh *linkHandler) TokenCreated(_ *charm.Link) { 30 | // Not implemented for the link participant 31 | } 32 | 33 | func (lh *linkHandler) TokenSent(_ *charm.Link) { 34 | lh.tokenSent <- struct{}{} 35 | } 36 | 37 | func (lh *linkHandler) ValidToken(_ *charm.Link) { 38 | lh.validToken <- true 39 | } 40 | 41 | func (lh *linkHandler) InvalidToken(_ *charm.Link) { 42 | lh.validToken <- false 43 | } 44 | 45 | func (lh *linkHandler) Request(_ *charm.Link) bool { 46 | // Not implemented for the link participant 47 | return false 48 | } 49 | 50 | func (lh *linkHandler) RequestDenied(_ *charm.Link) { 51 | lh.requestDenied <- struct{}{} 52 | } 53 | 54 | func (lh *linkHandler) SameUser(_ *charm.Link) { 55 | lh.success <- true 56 | } 57 | 58 | func (lh *linkHandler) Success(_ *charm.Link) { 59 | lh.success <- false 60 | } 61 | 62 | func (lh *linkHandler) Timeout(_ *charm.Link) { 63 | lh.timeout <- struct{}{} 64 | } 65 | 66 | func (lh *linkHandler) Error(_ *charm.Link) { 67 | lh.err <- errors.New("error") 68 | } 69 | -------------------------------------------------------------------------------- /ui/linkgen/linkhandler.go: -------------------------------------------------------------------------------- 1 | package linkgen 2 | 3 | import ( 4 | "errors" 5 | 6 | charm "github.com/charmbracelet/charm/proto" 7 | ) 8 | 9 | // linkRequest carries metadata pertaining to a link request. 10 | type linkRequest struct { 11 | pubKey string 12 | requestAddr string 13 | } 14 | 15 | // linkHandler implements the charm.LinkHandler interface. 16 | type linkHandler struct { 17 | err chan error 18 | token chan charm.Token 19 | request chan linkRequest 20 | response chan bool 21 | success chan bool 22 | timeout chan struct{} 23 | } 24 | 25 | func (lh *linkHandler) TokenCreated(l *charm.Link) { 26 | lh.token <- l.Token 27 | } 28 | 29 | func (lh *linkHandler) TokenSent(_ *charm.Link) {} 30 | 31 | func (lh *linkHandler) ValidToken(_ *charm.Link) {} 32 | 33 | func (lh *linkHandler) InvalidToken(_ *charm.Link) {} 34 | 35 | // Request handles link approvals. The remote machine sends an approval request, 36 | // which we send to the Tea UI as a message. The Tea application then sends a 37 | // response to the link handler's response channel with a command. 38 | func (lh *linkHandler) Request(l *charm.Link) bool { 39 | lh.request <- linkRequest{l.RequestPubKey, l.RequestAddr} 40 | return <-lh.response 41 | } 42 | 43 | func (lh *linkHandler) RequestDenied(_ *charm.Link) {} 44 | 45 | // Successful link, but this account has already been linked. 46 | func (lh *linkHandler) SameUser(_ *charm.Link) { 47 | lh.success <- true 48 | } 49 | 50 | func (lh *linkHandler) Success(_ *charm.Link) { 51 | lh.success <- false 52 | } 53 | 54 | func (lh *linkHandler) Timeout(_ *charm.Link) { 55 | lh.timeout <- struct{}{} 56 | } 57 | 58 | func (lh *linkHandler) Error(_ *charm.Link) { 59 | lh.err <- errors.New("there’s been an error; please try again") 60 | } 61 | -------------------------------------------------------------------------------- /ui/username/username.go: -------------------------------------------------------------------------------- 1 | package username 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/spinner" 7 | input "github.com/charmbracelet/bubbles/textinput" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/charm/client" 10 | charm "github.com/charmbracelet/charm/proto" 11 | "github.com/charmbracelet/charm/ui/common" 12 | ) 13 | 14 | type state int 15 | 16 | const ( 17 | ready state = iota 18 | submitting 19 | ) 20 | 21 | // index specifies the UI element that's in focus. 22 | type index int 23 | 24 | const ( 25 | textInput index = iota 26 | okButton 27 | cancelButton 28 | ) 29 | 30 | // NameSetMsg is sent when a new name has been set successfully. It contains 31 | // the new name. 32 | type NameSetMsg string 33 | 34 | // NameTakenMsg is sent when the requested username has already been taken. 35 | type NameTakenMsg struct{} 36 | 37 | // NameInvalidMsg is sent when the requested username has failed validation. 38 | type NameInvalidMsg struct{} 39 | 40 | type errMsg struct{ err error } 41 | 42 | func (e errMsg) Error() string { return e.err.Error() } 43 | 44 | // Model holds the state of the username UI. 45 | type Model struct { 46 | Done bool // true when it's time to exit this view 47 | Quit bool // true when the user wants to quit the whole program 48 | 49 | cc *client.Client 50 | styles common.Styles 51 | state state 52 | newName string 53 | index index 54 | errMsg string 55 | input input.Model 56 | spinner spinner.Model 57 | } 58 | 59 | // updateFocus updates the focused states in the model based on the current 60 | // focus index. 61 | func (m *Model) updateFocus() { 62 | if m.index == textInput && !m.input.Focused() { 63 | m.input.Focus() 64 | m.input.Prompt = m.styles.FocusedPrompt.String() 65 | } else if m.index != textInput && m.input.Focused() { 66 | m.input.Blur() 67 | m.input.Prompt = m.styles.Prompt.String() 68 | } 69 | } 70 | 71 | // Move the focus index one unit forward. 72 | func (m *Model) indexForward() { 73 | m.index++ 74 | if m.index > cancelButton { 75 | m.index = textInput 76 | } 77 | 78 | m.updateFocus() 79 | } 80 | 81 | // Move the focus index one unit backwards. 82 | func (m *Model) indexBackward() { 83 | m.index-- 84 | if m.index < textInput { 85 | m.index = cancelButton 86 | } 87 | 88 | m.updateFocus() 89 | } 90 | 91 | // NewModel returns a new username model in its initial state. 92 | func NewModel(cc *client.Client) Model { 93 | st := common.DefaultStyles() 94 | 95 | im := input.New() 96 | im.CursorStyle = st.Cursor 97 | im.Prompt = st.FocusedPrompt.String() 98 | im.CharLimit = 50 99 | im.Focus() 100 | 101 | im.Placeholder = "divagurl2000" 102 | if u, err := cc.Bio(); err == nil && u.Name != "" { 103 | im.Placeholder = u.Name 104 | } 105 | 106 | return Model{ 107 | Done: false, 108 | Quit: false, 109 | cc: cc, 110 | styles: st, 111 | state: ready, 112 | newName: "", 113 | index: textInput, 114 | errMsg: "", 115 | input: im, 116 | spinner: common.NewSpinner(), 117 | } 118 | } 119 | 120 | // Init is the Bubble Tea initialization function. 121 | func Init(cc *client.Client) func() (Model, tea.Cmd) { 122 | return func() (Model, tea.Cmd) { 123 | m := NewModel(cc) 124 | return m, InitialCmd() 125 | } 126 | } 127 | 128 | // InitialCmd returns the initial command. 129 | func InitialCmd() tea.Cmd { 130 | return input.Blink 131 | } 132 | 133 | // Update is the Bubble Tea update loop. 134 | func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { 135 | switch msg := msg.(type) { 136 | case tea.KeyMsg: 137 | switch msg.Type { 138 | case tea.KeyCtrlC: // quit 139 | m.Quit = true 140 | return m, nil 141 | case tea.KeyEscape: // exit this mini-app 142 | m.Done = true 143 | return m, nil 144 | 145 | default: 146 | // Ignore keys if we're submitting 147 | if m.state == submitting { 148 | return m, nil 149 | } 150 | 151 | switch msg.String() { 152 | case "tab": 153 | m.indexForward() 154 | case "shift+tab": 155 | m.indexBackward() 156 | case "l", "k", "right": 157 | if m.index != textInput { 158 | m.indexForward() 159 | } 160 | case "h", "j", "left": 161 | if m.index != textInput { 162 | m.indexBackward() 163 | } 164 | case "up", "down": 165 | if m.index == textInput { 166 | m.indexForward() 167 | } else { 168 | m.index = textInput 169 | m.updateFocus() 170 | } 171 | case "enter": 172 | switch m.index { 173 | case textInput: 174 | fallthrough 175 | case okButton: // Submit the form 176 | m.state = submitting 177 | m.errMsg = "" 178 | m.newName = strings.TrimSpace(m.input.Value()) 179 | 180 | return m, tea.Batch( 181 | setName(m), // fire off the command, too 182 | m.spinner.Tick, 183 | ) 184 | case cancelButton: // Exit this mini-app 185 | m.Done = true 186 | return m, nil 187 | } 188 | } 189 | 190 | // Pass messages through to the input element if that's the element 191 | // in focus 192 | if m.index == textInput { 193 | var cmd tea.Cmd 194 | m.input, cmd = m.input.Update(msg) 195 | 196 | return m, cmd 197 | } 198 | 199 | return m, nil 200 | } 201 | 202 | case NameTakenMsg: 203 | m.state = ready 204 | m.errMsg = m.styles.Subtle.Render("Sorry, ") + 205 | m.styles.Error.Render(m.newName) + 206 | m.styles.Subtle.Render(" is taken.") 207 | 208 | return m, nil 209 | 210 | case NameInvalidMsg: 211 | m.state = ready 212 | head := m.styles.Error.Render("Invalid name. ") 213 | body := m.styles.Subtle.Render("Names can only contain plain letters and numbers and must be less than 50 characters. And no emojis, kiddo.") 214 | m.errMsg = m.styles.Wrap.Render(head + body) 215 | 216 | return m, nil 217 | 218 | case errMsg: 219 | m.state = ready 220 | head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ") 221 | body := m.styles.Subtle.Render(msg.Error()) 222 | m.errMsg = m.styles.Wrap.Render(head + body) 223 | 224 | return m, nil 225 | 226 | case spinner.TickMsg: 227 | var cmd tea.Cmd 228 | m.spinner, cmd = m.spinner.Update(msg) 229 | 230 | return m, cmd 231 | 232 | default: 233 | var cmd tea.Cmd 234 | m.input, cmd = m.input.Update(msg) // Do we still need this? 235 | 236 | return m, cmd 237 | } 238 | } 239 | 240 | // View renders current view from the model. 241 | func View(m Model) string { 242 | s := "Enter a new username\n\n" 243 | s += m.input.View() + "\n\n" 244 | 245 | if m.state == submitting { 246 | s += spinnerView(m) 247 | } else { 248 | s += common.OKButtonView(m.index == 1, true) 249 | s += " " + common.CancelButtonView(m.index == 2, false) 250 | if m.errMsg != "" { 251 | s += "\n\n" + m.errMsg 252 | } 253 | } 254 | 255 | return s 256 | } 257 | 258 | func spinnerView(m Model) string { 259 | return m.spinner.View() + " Submitting..." 260 | } 261 | 262 | // Attempt to update the username on the server. 263 | func setName(m Model) tea.Cmd { 264 | return func() tea.Msg { 265 | // Validate before resetting the session to potentially save some 266 | // network traffic and keep things feeling speedy. 267 | if !client.ValidateName(m.newName) { 268 | return NameInvalidMsg{} 269 | } 270 | 271 | u, err := m.cc.SetName(m.newName) 272 | if err == charm.ErrNameTaken { 273 | return NameTakenMsg{} 274 | } else if err == charm.ErrNameInvalid { 275 | return NameInvalidMsg{} 276 | } else if err != nil { 277 | return errMsg{err} 278 | } 279 | 280 | return NameSetMsg(u.Name) 281 | } 282 | } 283 | --------------------------------------------------------------------------------