diff --git a/sources/identity/auth/connection.go b/sources/identity/auth/connection.go index b1194b41..3f2bb399 100644 --- a/sources/identity/auth/connection.go +++ b/sources/identity/auth/connection.go @@ -32,18 +32,27 @@ func GetConnectionMap(ctx context.Context) (*SafeConnectionMap, bool) { type SafeConnectionMap struct { mu sync.RWMutex - data map[string]Connection + data map[string]*Connection } // NewSafeConnectionMap creates and returns a new SafeConnectionMap func NewSafeConnectionMap() *SafeConnectionMap { return &SafeConnectionMap{ - data: make(map[string]Connection), + data: make(map[string]*Connection), } } -// Get safely retrieves an element from the map +// Get safely retrieves a copy of an element from the map func (sm *SafeConnectionMap) Get(key string) (Connection, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + val, ok := sm.data[key] + return *val, ok +} + +// GetRef safely retrieves a reference to an element from the map +// Be careful with this, as it allows you to modify the map +func (sm *SafeConnectionMap) GetRef(key string) (*Connection, bool) { sm.mu.RLock() defer sm.mu.RUnlock() val, ok := sm.data[key] @@ -51,7 +60,7 @@ func (sm *SafeConnectionMap) Get(key string) (Connection, bool) { } // Set safely adds an element to the map -func (sm *SafeConnectionMap) Set(key string, value Connection) { +func (sm *SafeConnectionMap) Set(key string, value *Connection) { sm.mu.Lock() defer sm.mu.Unlock() sm.data[key] = value @@ -66,6 +75,20 @@ func (sm *SafeConnectionMap) Delete(key string) { // All safely retrieves all elements from the map func (sm *SafeConnectionMap) All() map[string]Connection { + sm.mu.RLock() + defer sm.mu.RUnlock() + data := make(map[string]Connection, len(sm.data)) + for k, v := range sm.data { + data[k] = *v + } + return data +} + +// All safely retrieves all elements from the map +// Be careful with this, as it allows you to modify the map +// Specifically, it returns a copy of the map, so modifying the map will not modify the original +// But the elements are references, so modifying the elements will modify the original. +func (sm *SafeConnectionMap) AllRef() map[string]*Connection { sm.mu.RLock() defer sm.mu.RUnlock() return sm.data @@ -94,6 +117,18 @@ func (sm *SafeConnectionMap) Values() []Connection { sm.mu.RLock() defer sm.mu.RUnlock() values := make([]Connection, 0, len(sm.data)) + for _, v := range sm.data { + values = append(values, *v) + } + return values +} + +// ValuesRef safely retrieves all values from the map +// Be careful with this, as it allows you to modify the map +func (sm *SafeConnectionMap) ValuesRef() []*Connection { + sm.mu.RLock() + defer sm.mu.RUnlock() + values := make([]*Connection, 0, len(sm.data)) for _, v := range sm.data { values = append(values, v) } @@ -489,6 +524,14 @@ func (b *Connection) JSON() (string, error) { return string(jsonB), nil } +func (s *SafeConnectionMap) JSON() (string, error) { + jsonB, err := json.Marshal(s.All()) + if err != nil { + return "", fmt.Errorf("failed to marshal connection map: %v", err) + } + return string(jsonB), nil +} + func (b *Connection) HTML() (string, error) { jsonString, err := b.JSON() if err != nil { @@ -497,6 +540,20 @@ func (b *Connection) HTML() (string, error) { return "
" + jsonString + "
", nil } +func (s *SafeConnectionMap) Insert(connection *Connection) (*string, error) { + connectionID, err := connection.Insert() + if err != nil { + return nil, fmt.Errorf("failed to insert connection: %w", err) + } + s.Set(*connectionID, connection) + + if connection.CookieID != nil { + cookieID := *connection.CookieID + s.Set(cookieID, connection) + } + return connectionID, nil +} + func (b *Connection) Insert() (*string, error) { if b.ConnectionID != nil { return nil, fmt.Errorf("inserting connection with existing connection id") diff --git a/sources/identity/build-libsql.ps1 b/sources/identity/build-libsql.ps1 index d153e0b4..eb3bbbf7 100644 --- a/sources/identity/build-libsql.ps1 +++ b/sources/identity/build-libsql.ps1 @@ -1,7 +1,14 @@ #!/usr/bin/env pwsh param( [switch]$ForceInstallTempl, - [switch]$Update + [switch]$Update, + [switch]$SkipBuildWebJs, + [switch]$SkipBuildTempl, + [switch]$SkipBuildGoGenerate, + [switch]$SkipBuildGoModTidy, + [switch]$SkipBuildGoGet, + [switch]$SkipBuildGoBuild, + [switch]$SkipBuildGoExperiment ) Set-StrictMode -Version Latest @@ -17,6 +24,9 @@ if ($PSNativeCommandUseErrorActionPreference) { $originalVerbosePreference = $VerbosePreference $VerbosePreference = 'Continue' +Write-Verbose "script: $($MyInvocation.MyCommand.Name)" +Write-Verbose "psscriptroot: $PSScriptRoot" +Write-Verbose "full script path: $PSScriptRoot$([IO.Path]::DirectorySeparatorChar)$($MyInvocation.MyCommand.Name)" Write-Verbose "originalVerbosePreference: $originalVerbosePreference" Write-Verbose "VerbosePreference: $VerbosePreference" @@ -28,76 +38,99 @@ try { Write-Verbose "Current directory: $cwd" try { - - Write-Verbose "Set-Location $PSScriptRoot/web" - - Set-Location $PSScriptRoot/web - - if ($Update) { - Write-Verbose "npm install -g npm" - npm install -g npm - Write-Verbose "npm install -g npm-check-updates" - npm install -g npm-check-updates - Write-Verbose "ncu -g" - ncu -g - Write-Verbose "ncu -u" - ncu -u - Write-Verbose "sleep 1" - Start-Sleep 1 - Write-Verbose "npm install" - npm install - } else { - Write-Verbose "npm ci" - npm ci + if (-not $SkipBuildWebJs) { + + Write-Verbose "Set-Location $PSScriptRoot/web" + + Set-Location $PSScriptRoot/web + + if ($Update) { + Write-Verbose "npm install -g npm" + npm install -g npm + Write-Verbose "npm install -g npm-check-updates" + npm install -g npm-check-updates + Write-Verbose "ncu -g" + ncu -g + Write-Verbose "ncu -u" + ncu -u + Write-Verbose "sleep 1" + Start-Sleep 1 + Write-Verbose "npm install" + npm install + } else { + Write-Verbose "npm ci" + npm ci + } + + Write-Verbose "npm run build" + npm run build } - Write-Verbose "npm run build" - npm run build - Write-Verbose "Set-Location $PSScriptRoot" Set-Location $PSScriptRoot - if ([string]::IsNullOrEmpty($env:GOEXPERIMENT)) { - $env:GOEXPERIMENT = 'rangefunc' - Write-Verbose "Setting GOEXPERIMENT to $env:GOEXPERIMENT" + if (-not $SkipBuildGoExperiment) { + if ([string]::IsNullOrEmpty($env:GOEXPERIMENT)) { + $env:GOEXPERIMENT = 'rangefunc' + Write-Verbose "Setting GOEXPERIMENT to $env:GOEXPERIMENT" + } + Write-Verbose "GOEXPERIMENT: $env:GOEXPERIMENT" } - Write-Verbose "GOEXPERIMENT: $env:GOEXPERIMENT" - if ($Update) { + if ($Update -and -not $SkipBuildGoGet) { Write-Verbose "go get -u" go get -u + } else { + if ($SkipBuildGoGet) { + Write-Verbose "Skipping go get" + } + } + + if (-not $SkipBuildGoModTidy) { + Write-Verbose "go mod tidy" + go mod tidy + } else { + Write-Verbose "Skipping go mod tidy" } - Write-Verbose "go mod tidy" - go mod tidy + if (-not $SkipBuildTempl) { - Install-Templ -Force:$ForceInstallTempl + Install-Templ -Force:$ForceInstallTempl - Write-Verbose "templ fmt" - templ fmt . + Write-Verbose "templ fmt" + templ fmt . - Write-Verbose "templ generate" - $generateOutput = templ generate - Write-Verbose "templ generate output:" - $generateOutput -split "`n" | ForEach-Object { Write-Verbose ($_ -replace "\(Γ£ù\)", "❌" -replace "\(Γ£ô\)", "✅") } + Write-Verbose "templ generate" + $generateOutput = templ generate + Write-Verbose "templ generate output:" + $generateOutput -split "`n" | ForEach-Object { Write-Verbose ($_ -replace "\(Γ£ù\)", "❌" -replace "\(Γ£ô\)", "✅") } - if ($generateOutput -match '✗' -or $generateOutput -match 'Γ£ù') { - Write-Verbose "templ generate failed" - throw "templ generate failed" - } - else { - Write-Verbose "templ generate succeeded" + if ($generateOutput -match '✗' -or $generateOutput -match 'Γ£ù') { + Write-Verbose "templ generate failed" + throw "templ generate failed" + } + else { + Write-Verbose "templ generate succeeded" + } + } else { + Write-Verbose "Skipping templ build" } - Write-Verbose "go generate ./..." - go generate ./... + if (-not $SkipBuildGoGenerate) { + Write-Verbose "go generate ./..." + go generate ./... + } - $tags = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name) -replace '(?i)^build-', '' -replace '-', ',' -replace ' ', ',' + if (-not $SkipBuildGoBuild) { + $tags = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name) -replace '(?i)^build-', '' -replace '-', ',' -replace ' ', ',' - Write-Verbose "tags: $tags" + Write-Verbose "tags: $tags" - Write-Verbose "go build -v -tags $tags" - go build -v -tags $tags + Write-Verbose "go build -v -tags $tags" + go build -v -tags $tags + } else { + Write-Verbose "Skipping go build" + } } finally { Write-Verbose "Set-Location $cwd" diff --git a/sources/identity/build-sqlite.ps1 b/sources/identity/build-sqlite.ps1 index 584bf349..e70804f7 100644 --- a/sources/identity/build-sqlite.ps1 +++ b/sources/identity/build-sqlite.ps1 @@ -1,7 +1,14 @@ #!/usr/bin/env pwsh param( [switch]$ForceInstallTempl, - [switch]$Update + [switch]$Update, + [switch]$SkipBuildWebJs, + [switch]$SkipBuildTempl, + [switch]$SkipBuildGoGenerate, + [switch]$SkipBuildGoModTidy, + [switch]$SkipBuildGoGet, + [switch]$SkipBuildGoBuild, + [switch]$SkipBuildGoExperiment ) Set-StrictMode -Version Latest @@ -17,6 +24,9 @@ if ($PSNativeCommandUseErrorActionPreference) { $originalVerbosePreference = $VerbosePreference $VerbosePreference = 'Continue' +Write-Verbose "script: $($MyInvocation.MyCommand.Name)" +Write-Verbose "psscriptroot: $PSScriptRoot" +Write-Verbose "full script path: $PSScriptRoot$([IO.Path]::DirectorySeparatorChar)$($MyInvocation.MyCommand.Name)" Write-Verbose "originalVerbosePreference: $originalVerbosePreference" Write-Verbose "VerbosePreference: $VerbosePreference" @@ -28,73 +38,104 @@ try { Write-Verbose "Current directory: $cwd" try { - - Write-Verbose "Set-Location $PSScriptRoot/web" - - Set-Location $PSScriptRoot/web - - if ($Update) { - Write-Verbose "npm install -g npm" - npm install -g npm - Write-Verbose "npm install -g npm-check-updates" - npm install -g npm-check-updates - Write-Verbose "ncu -g" - ncu -g - Write-Verbose "npm install" - npm install - } - else { - Write-Verbose "npm ci" - npm ci + if (-not $SkipBuildWebJs) { + + Write-Verbose "Set-Location $PSScriptRoot/web" + + Set-Location $PSScriptRoot/web + + if ($Update) { + Write-Verbose "npm install -g npm" + npm install -g npm + Write-Verbose "npm install -g npm-check-updates" + npm install -g npm-check-updates + Write-Verbose "ncu -g" + ncu -g + Write-Verbose "ncu -u" + ncu -u + Write-Verbose "sleep 1" + Start-Sleep 1 + Write-Verbose "npm install" + npm install + } + else { + Write-Verbose "npm ci" + npm ci + } + + Write-Verbose "npm run build" + npm run build } - Write-Verbose "npm run build" - npm run build - Write-Verbose "Set-Location $PSScriptRoot" Set-Location $PSScriptRoot - if ([string]::IsNullOrEmpty($env:GOEXPERIMENT)) { - $env:GOEXPERIMENT = 'rangefunc' - Write-Verbose "Setting GOEXPERIMENT to $env:GOEXPERIMENT" + if (-not $SkipBuildGoExperiment) { + if ([string]::IsNullOrEmpty($env:GOEXPERIMENT)) { + $env:GOEXPERIMENT = 'rangefunc' + Write-Verbose "Setting GOEXPERIMENT to $env:GOEXPERIMENT" + } + Write-Verbose "GOEXPERIMENT: $env:GOEXPERIMENT" } - Write-Verbose "GOEXPERIMENT: $env:GOEXPERIMENT" - if ($Update) { + if ($Update -and -not $SkipBuildGoGet) { Write-Verbose "go get -u" go get -u } + else { + if ($SkipBuildGoGet) { + Write-Verbose "Skipping go get" + } + } - Write-Verbose "go mod tidy" - go mod tidy + if (-not $SkipBuildGoModTidy) { + Write-Verbose "go mod tidy" + go mod tidy + } + else { + Write-Verbose "Skipping go mod tidy" + } + + if (-not $SkipBuildTempl) { - Install-Templ -Force:$ForceInstallTempl + Install-Templ -Force:$ForceInstallTempl - Write-Verbose "templ fmt" - templ fmt . + Write-Verbose "templ fmt" + templ fmt . - Write-Verbose "templ generate" - $generateOutput = templ generate - Write-Verbose "templ generate output:" - $generateOutput -split "`n" | ForEach-Object { Write-Verbose ($_ -replace "\(Γ£ù\)", "❌" -replace "\(Γ£ô\)", "✅") } + Write-Verbose "templ generate" + $generateOutput = templ generate + Write-Verbose "templ generate output:" + $generateOutput -split "`n" | ForEach-Object { Write-Verbose ($_ -replace "\(Γ£ù\)", "❌" -replace "\(Γ£ô\)", "✅") } - if ($generateOutput -match '✗' -or $generateOutput -match 'Γ£ù') { - Write-Verbose "templ generate failed" - throw "templ generate failed" + if ($generateOutput -match '✗' -or $generateOutput -match 'Γ£ù') { + Write-Verbose "templ generate failed" + throw "templ generate failed" + } + else { + Write-Verbose "templ generate succeeded" + } } else { - Write-Verbose "templ generate succeeded" + Write-Verbose "Skipping templ build" } - Write-Verbose "go generate ./..." - go generate ./... + if (-not $SkipBuildGoGenerate) { + Write-Verbose "go generate ./..." + go generate ./... + } - $tags = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name) -replace '(?i)^build-', '' -replace '-', ',' -replace ' ', ',' + if (-not $SkipBuildGoBuild) { + $tags = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name) -replace '(?i)^build-', '' -replace '-', ',' -replace ' ', ',' - Write-Verbose "tags: $tags" + Write-Verbose "tags: $tags" - Write-Verbose "go build -v -tags $tags" - go build -v -tags $tags + Write-Verbose "go build -v -tags $tags" + go build -v -tags $tags + } + else { + Write-Verbose "Skipping go build" + } } finally { Write-Verbose "Set-Location $cwd" diff --git a/sources/identity/cmd/cmd.go b/sources/identity/cmd/cmd.go new file mode 100644 index 00000000..2d5cc9b4 --- /dev/null +++ b/sources/identity/cmd/cmd.go @@ -0,0 +1,925 @@ +package cmd + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + charmcmd "github.com/charmbracelet/charm/cmd" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + "github.com/charmbracelet/melt" + "github.com/charmbracelet/promwish" + "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" + "github.com/charmbracelet/wish/bubbletea" + "github.com/charmbracelet/wish/comment" + elapsed "github.com/charmbracelet/wish/elapsed" + "github.com/charmbracelet/wish/logging" + "github.com/charmbracelet/wish/scp" + "github.com/developing-today/code/src/identity/auth" + "github.com/developing-today/code/src/identity/configuration" + "github.com/developing-today/code/src/identity/observability" + "github.com/developing-today/code/src/identity/web" + "github.com/knadh/koanf" + "github.com/muesli/reflow/wordwrap" + "github.com/muesli/reflow/wrap" + "github.com/spf13/cobra" + gossh "golang.org/x/crypto/ssh" +) + +/* +// todo +embed default kdl file, +default kdl file -> +hard code vars in build -> +config file -> env vars -> +remote config ( + + s3 -> + db -> + nats -> + etc) ( + dont do all this, just this is the direction eventually as things become available if) + +// todo: dependency injection / remove globals and inits ??? +*/ +type errMsg error + +type model struct { + ready bool + content string + viewport viewport.Model + spinner spinner.Model + quitting bool + err error + term string + width int + height int + meltedPrivateKeySeed string + choices []string + cursor int + selected map[int]struct{} + charmId string + publicKeyAuthorized string +} + +var quitKeys = key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c"), + key.WithHelp("", "press q to quit"), +) + +func (m model) Init() tea.Cmd { + return m.spinner.Tick +} + +const useHighPerformanceRenderer = false + +var ( + titleStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Right = "├" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }() + + infoStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Left = "┤" + return titleStyle.Copy().BorderStyle(b) + }() +) + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + s := "Your term is %s\n" + s += "Your window size is x: %d y: %d\n\n" + + s = fmt.Sprintf(s, m.term, m.width, m.height) + + s += "Which room?\n\n" + + for i, choice := range m.choices { + + // Is the cursor pointing at this choice? + cursor := " " // no cursor + if m.cursor == i { + cursor = ">" // cursor! + } + + // Is this choice selected? + checked := " " // not selected + if _, ok := m.selected[i]; ok { + checked = "x" // selected! + } + + s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) + } + s += "\n" + + if m.meltedPrivateKeySeed != "" { + smelted := "Your private key seed is melted:\n\n%s\n\n" + s += fmt.Sprintf(smelted, m.meltedPrivateKeySeed) + } else { + authorizedPublicKeyText := "Your authorized public key is:\n\n%s\n\n" + s += fmt.Sprintf(authorizedPublicKeyText, m.publicKeyAuthorized) + } + charmIdText := "Your charm id is:\n\n%s\n\n" + s += fmt.Sprintf(charmIdText, m.charmId) + + if m.err != nil { + return m, tea.Quit + } + + s += fmt.Sprintf("\n %s Loading forever... %s\n\n", m.spinner.View(), quitKeys.Help().Desc) + + var wrapAt int + maxWrapMargin := 24 + leastWrapColumnWithMargin := 24 + mostWrapColumnBeforeMaxWrapMargin := 228 + + if m.width < leastWrapColumnWithMargin { + wrapAt = m.width + s = wrap.String(s, wrapAt) + } else { + var wrapAt int + if m.width <= mostWrapColumnBeforeMaxWrapMargin { + wrapAt = m.width - int(1+((m.width-(leastWrapColumnWithMargin+1))*maxWrapMargin)/(mostWrapColumnBeforeMaxWrapMargin-(leastWrapColumnWithMargin+1))) + } else { + wrapAt = m.width - (maxWrapMargin + 1) + } + s = wordwrap.String(s, wrapAt) + } + s = wrap.String(s, m.width) + if m.quitting { + return m, tea.Quit + } + m.viewport.SetContent(s) + + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + switch msg := msg.(type) { + + case tea.KeyMsg: // todo: super broken, fix this + if key.Matches(msg, quitKeys) { + m.quitting = true + return m, tea.Quit + } + + switch msg.String() { + // The "up" and "k" keys move the cursor up + case "w", "k": + if m.cursor > 0 { + m.cursor-- + } + + // The "down" and "j" keys move the cursor down + case "s", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + + // The "enter" key and the spacebar (a literal space) toggle + // the selected state for the item that the cursor is pointing at. + case "enter", " ": + _, ok := m.selected[m.cursor] + if ok { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = struct{}{} + } + } + case tea.WindowSizeMsg: + m.height = msg.Height + m.width = msg.Width + if !m.ready { + m.viewport = viewport.New(msg.Width, msg.Height) + m.viewport.KeyMap.Down.SetKeys("down") + m.viewport.KeyMap.Up.SetKeys("up") + m.ready = true + } else { + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height + } + case errMsg: + m.err = msg + default: + m.spinner, cmd = m.spinner.Update(msg) + } + + m.viewport, cmd = m.viewport.Update(msg) + + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m model) View() string { + return m.viewport.View() +} + +var separator = "." +var configurationFilePath = "config.kdl" +var embeddedConfigurationFilePath = "embed/config.kdl" +var generatedKeyDirPath = ".ssh/generated" +var hostKeyPath = ".ssh/term_info_ed25519" +var scpFileSystemDirPath = "scp" +var config = NewConfiguration() + +func initializeConfig() { + config = NewConfiguration() +} + +func NewConfiguration() configuration.IdentityServerConfiguration { + return configuration.IdentityServerConfiguration{ + Configuration: koanf.New(separator), + ConfigurationLocations: &configuration.ConfigurationLocations{ + ConfigurationFilePaths: []string{ + configurationFilePath, + // identity.kdl identity.config.kdl config.identity.kdl identity.config + // run these against ? binary dir ? pwd of execution ? appdata ? .config ? .local ??? + // then check for further locations/env-prefixes/etc from first pass, rerun on top with second pass + // (maybe config.kdl next to binary sets a new set of configurationPaths, finish out loading from defaults, then load from new paths) + // this pattern continues, after hard-code default env/file search, then custom file/env search, then eventually maybe nats/s3 or other remote or db config + }, + EmbeddedConfigurationFilePaths: []string{ + embeddedConfigurationFilePath, + }, + }, + EmbedFS: &configuration.EmbedFS, + } +} + +func initializeAndLoadConfiguration() { + initializeConfig() + log.Debug("Initialized config", "config", config.Configuration.Sprint()) + config.LoadConfiguration() + log.Info("Loaded config", "config", config.Configuration.Sprint()) +} + +func init() { + cobra.OnInitialize(initializeAndLoadConfiguration) + StartCharmCmd := charmcmd.ServeCmd + StartCharmCmd.Use = "charm" + StartCharmCmd.Aliases = []string{"ch", "c"} + StartAllCmd.AddCommand(StartCharmCmd) + StartAllCmd.AddCommand(StartIdentityCmd) + startAllAltCmd := *StartAllCmd + StartAllAltCmd = &startAllAltCmd + StartAllAltCmd.Use = "all" + StartAllAltCmd.Aliases = []string{"al", "a"} + StartAllCmd.AddCommand(StartAllAltCmd) + RootCmd.AddCommand(StartAllCmd) + RootCmd.AddCommand(charmcmd.RootCmd) +} + +var StartCharmCmd = &cobra.Command{} // constructed in init +var StartAllAltCmd = &cobra.Command{} // constructed in init + +var RootCmd = &cobra.Command{ + Use: "identity", + Short: "publish your identity", + Long: `publish your identity and allow others to connect to you.`, +} + +var StartAllCmd = &cobra.Command{ + Use: "start", + Short: "Starts the identity and charm servers", + Run: startAll, + Aliases: []string{"s", "run", "serve", "publish", "pub", "p", "i", "y", "u", "o", "p", "q", "w", "e", "r", "t", "a", "s", "d", "f", "g", "h", "j", "k", "l", "z", "x", "c", "v", "b"}, +} + +var StartIdentityCmd = &cobra.Command{ + Use: "identity", + Short: "Starts only the identity server", + Run: startIdentity, + Aliases: []string{"id", "i"}, +} + +func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { + pty, _, active := s.Pty() + if !active { + wish.Fatalln(s, "no active terminal, skipping") + return nil, nil + } + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + meltedPrivateKeySeed := s.Context().Permissions().Extensions["private-key-seed-melted"] + m := model{ + spinner: sp, + quitting: false, + err: nil, + term: pty.Term, + width: pty.Window.Width, + height: pty.Window.Height, + meltedPrivateKeySeed: meltedPrivateKeySeed, + choices: []string{"Chat", "Game", "Upload"}, + selected: make(map[int]struct{}), + charmId: s.Context().Permissions().Extensions["charm-id"], + publicKeyAuthorized: s.Context().Permissions().Extensions["public-key-authorized"], + } + return m, []tea.ProgramOption{tea.WithAltScreen()} +} + +func Banner(ctx ssh.Context) string { + return ` +Welcome to the identity server! ("The Service") + +By using The Service, you agree to all of the following terms and conditions. + +The user expressly understands and agrees that developing.today LLC, the operator of The Service, shall not be liable, in law or in equity, to them or to any third party for any direct, indirect, incidental, lost profits, special, consequential, punitive or exemplary damages. + +EACH PARTY MAKES NO WARRANTIES, EXPRESS, IMPLIED OR OTHERWISE, REGARDING ACCURACY, COMPLETENESS OR PERFORMANCE. + +THE SERVICE AND ANY RELATED SERVICES ARE PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS, WITHOUT WARRANTY OF ANY KIND, WHETHER WRITTEN OR ORAL, EXPRESS OR IMPLIED. + +TO THE FULL EXTENT PERMISSIBLE BY LAW, DEVELOPING.TODAY LLC WILL NOT BE LIABLE FOR ANY DAMAGES OF ANY KIND ARISING FROM THE USE OF ANY DEVELOPING.TODAY LLC SERVICE, OR FROM ANY INFORMATION, CONTENT, MATERIALS, PRODUCTS (INCLUDING SOFTWARE) OR OTHER SERVICES INCLUDED ON OR OTHERWISE MADE AVAILABLE TO YOU THROUGH ANY DEVELOPING.TODAY LLC SERVICE, INCLUDING, BUT NOT LIMITED TO DIRECT, INDIRECT, INCIDENTAL, PUNITIVE, AND CONSEQUENTIAL DAMAGES, UNLESS OTHERWISE SPECIFIED IN WRITING. + +TO THE MAXIMUM EXTENT ALLOWED BY LAW, DEVELOPING.TODAY LLC DISCLAIMS ALL WARRANTIES AND REPRESENTATIONS OF ANY KIND, INCLUDING WITHOUT LIMITATION THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT, WHETHER EXPRESS, IMPLIED, OR STATUTORY. DEVELOPING.TODAY LLC PROVIDES NO GUARANTEES THAT THE SERVICES OR NETWORK WILL FUNCTION WITHOUT INTERRUPTION OR ERRORS AND PROVIDES THE NETWORK, SERVICES, AND ANY RELATED CONTENT OR PRODUCTS SUBJECT TO THESE PUBLIC NETWORK TERMS ON AN “AS IS” BASIS. + +By submitting your content (all information you transmit to any developing.today LLC service) ("Your Content") you hereby grant to developing.today LLC an irrevocable, perpetual, royalty-free, worldwide right and license (with right to sublicense) to use, distribute, reproduce, create derivate works of, perform and display Your Content, in whole or part, on or off the any developing.today LLC service for any purpose, commercial or otherwise without acknowledgment, consent or monetary or other compensation to you. + +You hereby represent and warrant that: +- Your Content is an original work created by you +- You have all the rights and consents in and to Your Content necessary to grant the above license +- Your Content does not violate any privacy, publicity or any other applicable laws or regulations +- You understand and agree that developing.today LLC may use any information provided on this form or information available that is associated with your developing.today LLC account to contact you about Your Content. +- You agree that developing.today LLC has no obligation to exercise or exploit the above license. + +If you do not agree to all of the above terms and conditions, then you may not use The Service and must disconnect immediately. +` + fmt.Sprintf("You are using the identity server at %s:%d\n", config.Configuration.String("identity.server.host"), config.Configuration.Int("identity.server.ssh.port")) + ` +` + fmt.Sprintf("You are connecting from %s\n", ctx.RemoteAddr().String()) + ` +` + fmt.Sprintf("You are connecting from-with %s\n", ctx.RemoteAddr().Network()) + ` +` + fmt.Sprintf("You are connecting to %s\n", ctx.LocalAddr().String()) + ` +` + fmt.Sprintf("You are connecting to-with %s\n", ctx.LocalAddr().Network()) + ` +` + fmt.Sprintf("Your server version is %s\n", ctx.ServerVersion()) + ` +` + fmt.Sprintf("Your client version is %s\n", ctx.ClientVersion()) + ` +` + fmt.Sprintf("Your session id is %s\n", ctx.SessionID()) + ` +` + fmt.Sprintf("You are connecting with user %s\n", ctx.User()) +} + +func startAll(cmd *cobra.Command, args []string) { + var wg sync.WaitGroup + done := make(chan struct{}) // Channel to signal all tasks are done + + // Helper function for running tasks + runTask := func(taskFunc func(*cobra.Command, []string)) { + defer wg.Done() + + taskFunc(cmd, args) // Execute the task + // After task completion, optionally signal done for cleanup + } + + wg.Add(2) // Prepare for two goroutines + + // Start startCharm in its own goroutine + go runTask(func(cmd *cobra.Command, args []string) { + startCharm(cmd, args) + }) + + // Start startIdentity in its own goroutine + go runTask(func(cmd *cobra.Command, args []string) { + startIdentity(cmd, args) + }) + + go func() { + wg.Wait() // Wait for both tasks to complete + close(done) // Signal that all tasks are done + }() + + // Wait for the done signal before proceeding to cleanup or exit + <-done + fmt.Println("All tasks completed. Proceeding to cleanup and shutdown.") +} + +func startCharm(cmd *cobra.Command, args []string) { + charmcmd.ServeCmdRunE(cmd, args) +} + +func startIdentity(cmd *cobra.Command, args []string) { + // todo split web and ssh into separate functions + connections := auth.NewSafeConnectionMap() + web.GoRunWebServer(connections, &config) + handler := scp.NewFileSystemHandler(scpFileSystemDirPath) + s, err := wish.NewServer( + wish.WithMiddleware( + scp.Middleware(handler, handler), + bubbletea.Middleware(teaHandler), // todo: before bubbletea, use non-fullscreen teahandler to accept TOS if not this verion accepted in DB. check connection for previous tos, but this might need to be a charm user column? // separate todo: add tos table in database and pulldown latest tos on boot? + comment.Middleware("Thanks, have a nice day!"), + elapsed.Middleware(), + promwish.Middleware("0.0.0.0:9222", "identity"), + logging.Middleware(), + observability.Middleware(connections), + ), + wish.WithPasswordAuth(func(ctx ssh.Context, password string) bool { + log.Info("Accepting password", "password", password, "len", len(password)) + return Connect(ctx, nil, &password, nil, connections) + }), + wish.WithKeyboardInteractiveAuth(func(ctx ssh.Context, challenge gossh.KeyboardInteractiveChallenge) bool { + log.Info("Accepting keyboard interactive") + return Connect(ctx, nil, nil, challenge, connections) + }), + wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { + log.Info("Accepting public key", "publicKeyType", key.Type(), "publicKeyString", base64.StdEncoding.EncodeToString(key.Marshal())) + return Connect(ctx, key, nil, nil, connections) + }), + wish.WithBannerHandler(Banner), + wish.WithAddress(fmt.Sprintf("%s:%d", config.Configuration.String("identity.server.host"), config.Configuration.Int("identity.server.ssh.port"))), + wish.WithHostKeyPath(hostKeyPath), + ) + if err != nil { + log.Error("could not start server", "error", err) + return + } + + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + log.Info("Starting ssh server", "identity.server.host", config.Configuration.String("identity.server.host"), "identity.server.ssh.port", config.Configuration.Int("identity.server.ssh.port"), "address", fmt.Sprintf("%s:%d", config.Configuration.String("identity.server.host"), config.Configuration.Int("identity.server.ssh.port"))) + go func() { + if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { + log.Error("could not start server", "error", err) + done <- os.Interrupt + } + }() + + <-done + log.Info("Stopping ssh server", "identity.server.host", config.Configuration.String("identity.server.host"), "identity.server.ssh.port", config.Configuration.Int("identity.server.ssh.port"), "address", fmt.Sprintf("%s:%d", config.Configuration.String("identity.server.host"), config.Configuration.Int("identity.server.ssh.port"))) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { + log.Error("could not stop server", "error", err) + } +} + +type Challenge struct { + Name string + Instruction string + Questions []Question +} + +type Question struct { + Question string + Answer string + HideAnswer bool +} + +func (c Challenge) ExecuteMutable(challenge gossh.KeyboardInteractiveChallenge) ([]string, error) { + var questions []string + var showAnswers []bool + for _, question := range c.Questions { + questions = append(questions, question.Question) + showAnswers = append(showAnswers, !question.HideAnswer) + } + answers, err := challenge(c.Name, c.Instruction, questions, showAnswers) + if err != nil { + return nil, err + } + for i, answer := range answers { + c.Questions[i].Answer = answer + } + return answers, nil +} + +func Connect(ctx ssh.Context, key ssh.PublicKey, password *string, challenge gossh.KeyboardInteractiveChallenge, connections *auth.SafeConnectionMap) bool { + status := "open" + app := "identity" + connectionType := "ssh" + user := ctx.User() + var authMethod string + + if key != nil { + authMethod = "public-key" + } else if password != nil { + authMethod = "password" + } else if challenge != nil { + authMethod = "keyboard-interactive" + } else { + log.Error("No authentication method provided") + return false + } + + if ctx.Permissions().Extensions == nil { + ctx.Permissions().Extensions = make(map[string]string) + } + + var interactive *string + + if challenge != nil { + c := Challenge{ + Name: "Room Challenge:", + Instruction: "Select your room and enter the password if required.", + Questions: []Question{ + { + Question: "What is the room? ", + Answer: "", + }, + { + Question: "What is the password? (leave blank if none, password is sometimes required. Passwords are insecure, passwords may be visible to others.) ", + Answer: "", + HideAnswer: true, + }, + }, + } + _, err := c.ExecuteMutable(challenge) + if err != nil { + log.Error("Failed to get keyboard interactive response", "error", err) + return false + } + ctx.Permissions().Extensions["room"] = c.Questions[0].Answer + password = &c.Questions[1].Answer + + challengesJson, err := json.Marshal(c) + if err != nil { + log.Error("Failed to marshal challenges", "error", err) + return false + } + interactiveStr := string(challengesJson) + interactive = &interactiveStr + + log.Info("Accepting keyboard interactive", "response", interactiveStr, "len", len(interactiveStr)) + } + + var passwordLength *int64 + var passwordHash *string + var passwordHashType *string + var passwordSha256 []byte + var passwordSha256Str string + + if password != nil { + log.Info("Accepting password", "password", *password, "len", len(*password)) + passwordLength = new(int64) + *passwordLength = int64(len(*password)) + hasher := sha256.New() + hasher.Write([]byte(*password)) + passwordSha256 = hasher.Sum(nil) + passwordSha256Str = base64.StdEncoding.EncodeToString(passwordSha256) + passwordHash = &passwordSha256Str + ctx.Permissions().Extensions["password-hash"] = *passwordHash + + passwordHashTypeStr := "sha256" + passwordHashType = &passwordHashTypeStr + ctx.Permissions().Extensions["password-hash-type"] = *passwordHashType + + log.Info("Accepting password", "passwordHash", *passwordHash) + } + + var publicKey *string + var publicKeyType string + + if key != nil { + log.Info("Accepting public key", "publicKeyType", key.Type(), "publicKeyString", base64.StdEncoding.EncodeToString(key.Marshal())) + publicKeyStr := base64.StdEncoding.EncodeToString(key.Marshal()) + publicKey = &publicKeyStr + publicKeyType = key.Type() + ctx.Permissions().Extensions["public-key"] = *publicKey + ctx.Permissions().Extensions["public-key-type"] = publicKeyType + } + var textKeyId *int64 + var hashKeyId *int64 + var ed25519PrivateKey ed25519.PrivateKey + var ed25519PublicKey ed25519.PublicKey + var privateKeyId *int64 + + if publicKey == nil { + log.Info("No public key provided, gathering one") + if password == nil || passwordLength == nil || passwordHash == nil || passwordHashType == nil || passwordSha256 == nil { + log.Error("No public key or password provided", "password", *password, "passwordLength", *passwordLength, "passwordHash", *passwordHash, "passwordHashType", *passwordHashType, "passwordSha256", passwordSha256) + return false + } + + if interactive != nil { + publicKeyStr, err := auth.GetPublicKeyFromText(passwordSha256Str, "%") + if err != nil { + log.Info("Failed to get public key from text", "error", err) + } else { + log.Info("Got public key from text", "publicKeyStr", publicKeyStr) + if publicKeyStr != "" { + out, comment, options, rest, err := gossh.ParseAuthorizedKey([]byte(publicKeyStr)) + if err != nil { + log.Error("Failed to parse public key", "error", err) + return false + } + log.Info("Parsed public key", "out", out, "comment", comment, "options", options, "rest", rest) + publicKey = &publicKeyStr + publicKeyType = out.Type() + + key = out + log.Info("Gathered public key", "publicKey", publicKeyStr) + ctx.Permissions().Extensions["public-key-type"] = publicKeyType + ctx.Permissions().Extensions["public-key-authorized"] = publicKeyStr + log.Info("Setting permissions extensions", "public-key-type", publicKeyType, "public-key-authorized", publicKeyStr, "public-key", publicKeyStr, "public-key-type", publicKeyType) + } + } + } else { + publicKeyStr, err := auth.GetPublicKeyFromHash(passwordSha256Str, "%") + if err != nil { + log.Info("Failed to get public key from hash", "error", err) + } else { + log.Info("Got public key from hash", "publicKeyStr", publicKeyStr) + if publicKeyStr != "" { + out, comment, options, rest, err := gossh.ParseAuthorizedKey([]byte(publicKeyStr)) + if err != nil { + log.Error("Failed to parse public key", "error", err) + return false + } + log.Info("Parsed public key", "out", out, "comment", comment, "options", options, "rest", rest) + publicKey = &publicKeyStr + publicKeyType = out.Type() + + key = out + log.Info("Gathered public key", "publicKey", publicKeyStr) + ctx.Permissions().Extensions["public-key-type"] = publicKeyType + ctx.Permissions().Extensions["public-key-authorized"] = publicKeyStr + log.Info("Setting permissions extensions", "public-key-type", publicKeyType, "public-key-authorized", publicKeyStr, "public-key", publicKeyStr, "public-key-type", publicKeyType) + } + } + } + if key == nil { + log.Info("No public key found, generating one") + if interactive != nil { + ed25519PrivateKey = ed25519.NewKeyFromSeed(passwordSha256) + ed25519PublicKey = ed25519PrivateKey.Public().(ed25519.PublicKey) + } else { + var err error + ed25519PublicKey, ed25519PrivateKey, err = ed25519.GenerateKey(nil) + if err != nil { + log.Error("Failed to generate private key", "error", err) + return false + } + } + log.Info("Generated private key", "pk", ed25519PrivateKey, "pkLen", len(ed25519PrivateKey), "pkStr", base64.StdEncoding.EncodeToString(ed25519PrivateKey)) + + privateKeyIdi, err := auth.InsertPrivateKey(ed25519PrivateKey) + if err != nil { + log.Error("Failed to insert private key", "error", err) + return false + } + privateKeyId = &privateKeyIdi + + log.Info("Generated public key", "pk", ed25519PublicKey, "pkLen", len(ed25519PublicKey), "pkStr", base64.StdEncoding.EncodeToString(ed25519PublicKey), "privateKeyId", *privateKeyId) + ctx.Permissions().Extensions["private-key-seed"] = base64.StdEncoding.EncodeToString(ed25519PrivateKey.Seed()) + ctx.Permissions().Extensions["private-key"] = base64.StdEncoding.EncodeToString(ed25519PrivateKey) + ctx.Permissions().Extensions["private-key-type"] = "ed25519" + ctx.Permissions().Extensions["public-key"] = base64.StdEncoding.EncodeToString(ed25519PublicKey) + ctx.Permissions().Extensions["public-key-type"] = "ed25519" + + sshPubKey, err := gossh.NewPublicKey(ed25519PublicKey) + if err != nil { + log.Fatal("Failed to create SSH public key", err) + } + + if interactive != nil { + textKeyIdi, err := auth.InsertTextPublicKey(passwordSha256Str, "sha256", sshPubKey) + if err != nil { + log.Error("Failed to insert text public key", "error", err) + return false + } + textKeyId = &textKeyIdi + log.Info("Inserted text public key", "textKeyId", *textKeyId) + } else { + hashKeyIdi, err := auth.InsertHashPublicKey(passwordSha256Str, "sha256", sshPubKey) + if err != nil { + log.Error("Failed to insert hash public key", "error", err) + return false + } + hashKeyId = &hashKeyIdi + log.Info("Inserted hash public key", "hashKeyId", *hashKeyId) + } + + authorizedKey := gossh.MarshalAuthorizedKey(sshPubKey) + authKey := string(authorizedKey) + log.Info("Generated public key", "authKey", authKey, "authorizedKey", authorizedKey, "sshPubKey", sshPubKey, "sshPubKeyStr", string(sshPubKey.Marshal())) + ctx.Permissions().Extensions["public-key-authorized"] = authKey + + publicKeyStr := base64.StdEncoding.EncodeToString(authorizedKey) + log.Info("Generated public key", "publicKeyStr", publicKeyStr) + + publicKey = &publicKeyStr + publicKeyType = "ed25519" + log.Info("Generated public key", "publicKey", *publicKey) + parts := strings.Fields(string(authorizedKey)) + if len(parts) < 2 { + log.Fatal("Invalid public key format") + } + keyData, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + log.Fatal("Failed to decode base64 public key", err) + } + log.Info("Generated public key, preparing", "keyData", keyData, "keyDataLen", len(keyData), "parts", parts, "publicKey", *publicKey) + + out, comment, options, rest, err := gossh.ParseAuthorizedKey(authorizedKey) + if err != nil { + log.Fatal("Failed to parse public key", "error", err) + } + log.Info("Parsed public key", "out", out, "comment", comment, "options", options, "rest", rest) + key = out + log.Info("Generated public key", "publicKey", publicKeyStr) + ctx.Permissions().Extensions["public-key"] = *publicKey + ctx.Permissions().Extensions["public-key-type"] = publicKeyType + pkMelted, err := melt.ToMnemonic(&ed25519PrivateKey) + if err != nil { + log.Error("Failed to melt private key", "error", err) + return false + } + ctx.Permissions().Extensions["private-key-seed-melted"] = pkMelted + log.Info("Melted private key", "pkMelted", pkMelted) + } + } else { + log.Info("Public key provided", "publicKey", *publicKey, "key", key, "keyType", key.Type(), "keyMarshal", key.Marshal(), "keyMarshalLen", len(key.Marshal())) + } + + if publicKey == nil { + log.Error("No public key provided") + return false + } + + authorizedKey := gossh.MarshalAuthorizedKey(key) + log.Info("Public key used", "publicKey", authorizedKey) + + serverVersion := ctx.ServerVersion() + clientVersion := ctx.ClientVersion() + sessionHash := ctx.SessionID() + permissionsCriticalOptionsJson, err := json.Marshal(ctx.Permissions().CriticalOptions) + if err != nil { + log.Error("Failed to marshal critical options", "error", err) + return false + } + permissionsCriticalOptions := string(permissionsCriticalOptionsJson) + host := ctx.LocalAddr().String() + port := int64(ctx.LocalAddr().(*net.TCPAddr).Port) + remoteAddr := ctx.RemoteAddr().String() + remoteAddrNetwork := ctx.RemoteAddr().Network() + openedAt := time.Now() + pty := "" + protocol := "ssh" + permissionsExtensions := "" + admin := "" + query := "" + commands := "" + comments := "" + history := "" + + log.Info("Connection opened", "openedAt", openedAt, "remoteAddr", remoteAddr, "remoteAddrNetwork", remoteAddrNetwork, "host", host, "port", port, "serverVersion", serverVersion, "clientVersion", clientVersion, "sessionHash", sessionHash, "permissionsCriticalOptions", permissionsCriticalOptions) + + interactiveStr := "" + if interactive != nil { + interactiveStr = *interactive + } + + connection := auth.Connection{ + Status: &status, + Name: &user, + Description: &user, + App: &app, + AuthMethod: &authMethod, + Type: &connectionType, + Username: &user, + PublicKey: publicKey, + ServerVersion: &serverVersion, + ClientVersion: &clientVersion, + SessionHash: &sessionHash, + PermissionsCriticalOptions: &permissionsCriticalOptions, + PermissionsExtensions: &permissionsExtensions, + Host: &host, + Port: port, + Pty: &pty, + Protocol: &protocol, + RemoteAddr: &remoteAddr, + RemoteAddrNetwork: &remoteAddrNetwork, + OpenedAt: &openedAt, + Interactive: &interactiveStr, + PasswordLength: passwordLength, + PasswordHash: passwordHash, + PasswordHashType: passwordHashType, + Admin: &admin, + Query: &query, + Commands: &commands, + Comments: &comments, + History: &history, + } + + log.Info("Inserting connection", "connection", connection.ToData(), "connectionID", connection.ConnectionID) + connectionID, err := connection.Insert() + + if err != nil { + log.Error("Failed to insert connection", "error", err, "connectionID", connection.ConnectionID) + return false + } + log.Info("Inserted connection", "connectionID", &connectionID, "connection", connection.String(), "connectionID", connection.ConnectionID) + ctx.Permissions().Extensions["connection-id"] = *connectionID + + permissionsExtensionsJson, err := json.Marshal(ctx.Permissions().Extensions) + if err != nil { + log.Error("Failed to marshal extensions", "error", err, "connectionID", connection.ConnectionID) + return false + } + log.Info("Setting permissions extensions", "permissionsExtensions", string(permissionsExtensionsJson), "connectionID", connection.ConnectionID) + connection.SetPermissionsExtensions(string(permissionsExtensionsJson)) + + log.Info("Checking public key", "publicKey", *publicKey, "connectionID", connection.ConnectionID) + result, err := auth.CheckPublicKey(ctx, key) + + log.Info("Checked public key", "result", result, "error", err, "connectionID", connection.ConnectionID) + if err != nil { + var userID int64 + userID, err = auth.InsertUser(ctx) + if err != nil { + log.Error("Failed to insert user", "error", err, "connectionID", connection.ConnectionID) + return false + } + log.Info("Inserted user", "userID", userID, "connectionID", connection.ConnectionID) + + var pk int64 + pk, err = auth.InsertPublicKey(userID, key) + if err != nil { + log.Error("Failed to insert public key", "error", err, "connectionID", connection.ConnectionID) + return false + } + log.Info("Inserted public key", "pk", pk, "connectionID", connection.ConnectionID) + + result, err = auth.CheckPublicKey(ctx, key) + + log.Info("Checked public key", "result", result, "error", err, "connectionID", connection.ConnectionID) + } else { + log.Info("Public key already exists", "result", result, "connectionID", connection.ConnectionID) + } + if err != nil { + log.Error("Failed to check public key", "error", err, "connectionID", connection.ConnectionID) + return false + } + connection.SetCharmID(result.ID) + if ed25519PrivateKey != nil { + affected, err := auth.UpdatePrivateKey(*privateKeyId, &result.ID, connectionID) + if err != nil { + log.Error("Failed to update private key", "error", err, "connectionID", connection.ConnectionID) + return false + } + log.Info("Updated private key", "affected", affected, "connectionID", connection.ConnectionID) + if affected < 1 { + log.Error("Failed to update private key, affected 0", "error", err, "connectionID", connection.ConnectionID) + return false + } + } + if textKeyId != nil { + affected, err := auth.UpdateTextPublicKey(*textKeyId, &result.ID, connectionID) + if err != nil { + log.Error("Failed to update text public key", "error", err, "connectionID", connection.ConnectionID) + return false + } + log.Info("Updated text public key", "affected", affected, "connectionID", connection.ConnectionID) + + if affected < 1 { + log.Error("Failed to update text public key, affected 0", "error", err, "connectionID", connection.ConnectionID) + return false + } + } + if hashKeyId != nil { + affected, err := auth.UpdateHashPublicKey(*hashKeyId, &result.ID, connectionID) + if err != nil { + log.Error("Failed to update hash public key", "error", err, "connectionID", connection.ConnectionID) + return false + } + log.Info("Updated hash public key", "affected", affected, "connectionID", connection.ConnectionID) + if affected < 1 { + log.Error("Failed to update hash public key, affected 0", "error", err, "connectionID", connection.ConnectionID) + return false + } + } + ctx.Permissions().Extensions["charm-id"] = result.ID + connections.Set(*connection.ConnectionID, &connection) + ctx.SetValue("connection", connection) + ctx.Permissions().Extensions["charm-name"] = result.Name + log.Info("Setting permissions extensions", "charm-id", result.ID, "charm-name", result.Name, "connectionID", connection.ConnectionID) + jsonRoles, err := json.Marshal(result.Roles) + if err != nil { + log.Error("Failed to marshal roles", "error", err) + return false + } + log.Info("Setting permissions extensions", "charm-roles", string(jsonRoles)) + ctx.Permissions().Extensions["charm-roles"] = string(jsonRoles) + ctx.Permissions().Extensions["charm-created-at"] = result.CreatedAt.Format(time.RFC3339) + ctx.Permissions().Extensions["charm-public-key-created-at"] = result.PublicKeyCreatedAt.Format(time.RFC3339) + ctx.Permissions().Extensions["charm-public-key-type"] = result.PublicKeyType + ctx.Permissions().Extensions["charm-public-key"] = result.PublicKeyString + + log.Info("Setting permissions extensions", "charm-created-at", result.CreatedAt.Format(time.RFC3339), "charm-public-key-created-at", result.PublicKeyCreatedAt.Format(time.RFC3339), "charm-public-key-type", result.PublicKeyType, "charm-public-key", result.PublicKeyString) + + return true +} diff --git a/sources/identity/config.kdl b/sources/identity/config.kdl index a8927dc7..ad579ee0 100644 --- a/sources/identity/config.kdl +++ b/sources/identity/config.kdl @@ -1,6 +1,14 @@ -identity.server port=1 host="0.0.0.0" { - jwt audience="identity" { +identity.server ssh.port=1 host="0.0.0.0" web.port=7000 { + authorization { + cookie_name "Authorization" + header_name "Authorization" + header_prefix "Bearer" + } + jwt { + audience "identity" issuer "http://laptop-framework.raptor-pumpkinseed.ts.net:35354" jwks "http://laptop-framework.raptor-pumpkinseed.ts.net:35354/.well-known/jwks.json" + cache_ttl "10m" } } +// todo throw on invalid files error diff --git a/sources/identity/configuration/configuration.go b/sources/identity/configuration/configuration.go new file mode 100644 index 00000000..d8a3283c --- /dev/null +++ b/sources/identity/configuration/configuration.go @@ -0,0 +1,154 @@ +package configuration + +import ( + "embed" + "os" + "strings" + "time" + + "github.com/auth0/go-jwt-middleware/v2/jwks" + "github.com/charmbracelet/log" + "github.com/developing-today/code/src/identity/configuration/namespace" + "github.com/knadh/koanf" + "github.com/knadh/koanf/parsers/kdl" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/rawbytes" +) + +//go:embed all:embed +var EmbedFS embed.FS + +type ConfigurationLocations struct { + ConfigurationFilePaths []string + EmbeddedConfigurationFilePaths []string +} + +type IdentityServerConfiguration struct { + Configuration *koanf.Koanf + ConfigurationLocations *ConfigurationLocations + EmbedFS *embed.FS + JWKSProvider *jwks.Provider + JWTAudience []string +} + +func GetEnv(key string, defaultValue string) string { + value := os.Getenv(strings.ToUpper(key)) + if value == "" { + return defaultValue + } + return value +} + +var ( + Prefix = GetEnv(namespace.Prefix, "dt") +) + +func (c *IdentityServerConfiguration) LoadConfiguration() { + // lower, replace prefix with identity.", also populate "" and "identity.server." + // IDENTITY_SERVER_* + // lower, replace prefix with "identity.", also populate "" + // IDENTITY_* + // lower, replace prefix with "charm.", also populate "charm.server." + // CHARM_SERVER_* + // lower, replace prefix with "charm." + // CHARM_* + + // example of loading env var + // Load environment variables and merge into the loaded config. + // "MYVAR" is the prefix to filter the env vars by. + // "." is the delimiter used to represent the key hierarchy in env vars. + // The (optional, or can be nil) function can be used to transform + // the env var names, for instance, to lowercase them. + // + // For example, env vars: MYVAR_TYPE and MYVAR_PARENT1_CHILD1_NAME + // will be merged into the "type" and the nested "parent1.child1.name" + // keys in the config file here as we lowercase the key, + // replace `_` with `.` and strip the MYVAR_ prefix so that + // only "parent1.child1.name" remains. + // k.Load(env.Provider("MYVAR_", ".", func(s string) string { + // return strings.Replace(strings.ToLower( + // strings.TrimPrefix(s, "MYVAR_")), "_", ".", -1) + // }), nil) + + // decide order of loading, defaults, env, file, env, file, remotes, etc. + // always overwrite previous? + // ever skip if already set? + + // for file loading: + // maybe. not sure yet, especially for "". + // 1. loading from file as "" + // 2. loading from file as "identity.", put 1 in 2 where not set + // 3. loading from file as "identity.server.", put 2 in 3 where not set + // if i set port=1 and identity.server.ssh.port=3, then ""1,"identity"1,"identity.server"3 + // if i set port=1 and identity.port=3, then ""1,"identity"3,"identity.server"3 + + // IDENTITY_SERVER_AUTHORIZATION_HEADER_PREFIX_PATH := "identity.server.authorization.header_name" + // if os.Getenv("IDENTITY_SERVER_AUTHORIZATION_HEADER_NAME") != "" { + // c.Configuration.Set(IDENTITY_SERVER_AUTHORIZATION_HEADER_PREFIX_PATH, os.Getenv("IDENTITY_SERVER_AUTHORIZATION_HEADER_NAME")) + // } + // c.Configuration.Set(IDENTITY_SERVER_AUTHORIZATION_HEADER_PREFIX_PATH, "Bearer") + + prefix := os.Getenv("DT_PREFIX") // todo allow overrides + if prefix == "" { + prefix = "dt" + } + c.Configuration.Set("prefix", prefix) + + c.Configuration.Set("identity.server.authorization.cookie_name", "Authorization") + c.Configuration.Set("identity.server.authorization.header_name", "Authorization") + + // IDENTITY_SERVER_AUTHORIZATION_HEADER_NAME := os.Getenv("IDENTITY_SERVER_AUTHORIZATION_HEADER_NAME") + + c.Configuration.Set("identity.server.authorization.header_prefix", "Bearer") + c.Configuration.Set("identity.server.host", "0.0.0.0") + c.Configuration.Set("identity.server.jwt.audience", "identity") + c.Configuration.Set("identity.server.jwt.cache_ttl", time.Duration(15)*time.Minute) + c.Configuration.Set("identity.server.port", 1) + c.Configuration.Set("identity.server.web.port", 7000) + + log.Info("Loaded default configuration", "config", c.Configuration.Sprint()) + log.Info("Loading embedded file configuration", "config", c.ConfigurationLocations.EmbeddedConfigurationFilePaths) + + for _, path := range c.ConfigurationLocations.EmbeddedConfigurationFilePaths { + data, err := c.EmbedFS.ReadFile(path) + if err != nil { + log.Info("Embedded Config not found or error reading", "path", path, "error", err) + continue + } + + if err := c.Configuration.Load(rawbytes.Provider(data), kdl.Parser()); err != nil { + log.Error("Failed to load embedded config", "error", err) + } else { + log.Info("Loaded config from embedded file", "path", path) + } + } + + log.Info("Loaded embedded configuration", "config", c.Configuration.Sprint()) + log.Info("Loading environment configuration", "environment_variable_prefix", prefix, "lvl", "WARN") + + c.Configuration.Load(env.Provider(prefix, ".", func(s string) string { + return strings.Replace(strings.Replace(strings.Replace(strings.ToLower( + strings.TrimPrefix(s, prefix)), + "__", " ", -1), + "_", ".", -1), + " ", "_", -1) + }), nil) + + log.Info("Loaded environment configuration", "config", c.Configuration.Sprint()) + log.Info("Loading file configuration", "paths", c.ConfigurationLocations.ConfigurationFilePaths) + + for _, path := range c.ConfigurationLocations.ConfigurationFilePaths { + if _, err := os.Stat(path); err == nil { + if err := c.Configuration.Load(file.Provider(path), kdl.Parser()); err != nil { + log.Error("Failed to load file config", "error", err) + } else { + log.Info("Loaded config from file", "path", path) + } + } else { + log.Info("Config file not found", "path", path) + } + } + + log.Info("Loaded file configuration", "config", c.Configuration.Sprint()) +} diff --git a/sources/identity/configuration/embed/config.kdl b/sources/identity/configuration/embed/config.kdl new file mode 100644 index 00000000..99bb2f70 --- /dev/null +++ b/sources/identity/configuration/embed/config.kdl @@ -0,0 +1,8 @@ +identity.server port=1 host="0.0.0.0" { + jwt { + audience "identity" + issuer "http://laptop-framework.raptor-pumpkinseed.ts.net:35354" + jwks "http://laptop-framework.raptor-pumpkinseed.ts.net:35354/.well-known/jwks.json" + } +} +// todo throw on invalid files diff --git a/sources/identity/configuration/namespace/namespace.go b/sources/identity/configuration/namespace/namespace.go new file mode 100644 index 00000000..ed398bed --- /dev/null +++ b/sources/identity/configuration/namespace/namespace.go @@ -0,0 +1,5 @@ +package namespace + +var ( + Prefix = "Prefix" +) diff --git a/sources/identity/go.mod b/sources/identity/go.mod index b4bacef4..52b8cff7 100644 --- a/sources/identity/go.mod +++ b/sources/identity/go.mod @@ -5,6 +5,7 @@ go 1.22 require ( github.com/a-h/templ v0.2.543 github.com/angelofallars/htmx-go v0.5.0 + github.com/auth0/go-jwt-middleware/v2 v2.2.1 github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/charm v0.12.6 @@ -14,7 +15,8 @@ require ( github.com/charmbracelet/promwish v0.7.1-0.20240111010057-b31c9c27b2d7 github.com/charmbracelet/ssh v0.0.0-20240202115812-f4ab1009799a github.com/charmbracelet/wish v1.3.1 - github.com/go-chi/chi/v5 v5.0.11 + github.com/go-chi/chi v1.5.5 + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/gowebly/helpers v0.3.0 github.com/knadh/koanf v1.5.1-0.20230129133018-38c5fe1d9b40 github.com/knadh/koanf/parsers/kdl v0.1.1 @@ -23,6 +25,7 @@ require ( github.com/spf13/cobra v1.8.0 github.com/tursodatabase/libsql-client-go v0.0.0-20240208053015-5d6aa1e2196d golang.org/x/crypto v0.19.0 + gopkg.in/go-jose/go-jose.v2 v2.6.2 ) replace github.com/charmbracelet/charm => github.com/developing-today-forks/charm v0.12.7-0.20240209064850-d1e7d27803f5 @@ -31,7 +34,6 @@ require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/auth0/go-jwt-middleware/v2 v2.2.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/caarlos0/env/v6 v6.10.1 // indirect @@ -103,7 +105,6 @@ require ( golang.org/x/term v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect google.golang.org/protobuf v1.32.0 // indirect - gopkg.in/go-jose/go-jose.v2 v2.6.2 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.41.0 // indirect diff --git a/sources/identity/go.sum b/sources/identity/go.sum index afcc351b..5b2c3565 100644 --- a/sources/identity/go.sum +++ b/sources/identity/go.sum @@ -118,8 +118,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= -github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -135,6 +135,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= diff --git a/sources/identity/main.go b/sources/identity/main.go index 158a45d1..29929088 100644 --- a/sources/identity/main.go +++ b/sources/identity/main.go @@ -1,955 +1,15 @@ package main import ( - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" "fmt" - "net" "os" - "os/signal" - "strings" - "sync" - "syscall" - "time" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - charmcmd "github.com/charmbracelet/charm/cmd" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/log" - "github.com/charmbracelet/melt" - "github.com/charmbracelet/promwish" - "github.com/charmbracelet/ssh" - "github.com/charmbracelet/wish" - "github.com/charmbracelet/wish/bubbletea" - "github.com/charmbracelet/wish/comment" - elapsed "github.com/charmbracelet/wish/elapsed" - "github.com/charmbracelet/wish/logging" - "github.com/charmbracelet/wish/scp" - "github.com/developing-today/code/src/identity/auth" - "github.com/developing-today/code/src/identity/observability" - "github.com/developing-today/code/src/identity/web" - "github.com/knadh/koanf" - "github.com/knadh/koanf/parsers/kdl" - "github.com/knadh/koanf/providers/file" - "github.com/muesli/reflow/wordwrap" - "github.com/muesli/reflow/wrap" - "github.com/spf13/cobra" - gossh "golang.org/x/crypto/ssh" + "github.com/developing-today/code/src/identity/cmd" ) -// todo embed default kdl file, default kdl file -> hard code vars in build -> config file -> env vars -> remote config (s3 -> db -> nats -> etc) (dont do all this, just this is the direction eventually as things become available if) -// todo: dependency injection / remove globals and inits ??? - -type errMsg error - -type model struct { - ready bool - content string - viewport viewport.Model - spinner spinner.Model - quitting bool - err error - term string - width int - height int - meltedPrivateKeySeed string - choices []string - cursor int - selected map[int]struct{} - charmId string - publicKeyAuthorized string -} - -var quitKeys = key.NewBinding( - key.WithKeys("q", "esc", "ctrl+c"), - key.WithHelp("", "press q to quit"), -) - -func (m model) Init() tea.Cmd { - return m.spinner.Tick -} - -const useHighPerformanceRenderer = false - -var ( - titleStyle = func() lipgloss.Style { - b := lipgloss.RoundedBorder() - b.Right = "├" - return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) - }() - - infoStyle = func() lipgloss.Style { - b := lipgloss.RoundedBorder() - b.Left = "┤" - return titleStyle.Copy().BorderStyle(b) - }() -) - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - s := "Your term is %s\n" - s += "Your window size is x: %d y: %d\n\n" - - s = fmt.Sprintf(s, m.term, m.width, m.height) - - s += "Which room?\n\n" - - for i, choice := range m.choices { - - // Is the cursor pointing at this choice? - cursor := " " // no cursor - if m.cursor == i { - cursor = ">" // cursor! - } - - // Is this choice selected? - checked := " " // not selected - if _, ok := m.selected[i]; ok { - checked = "x" // selected! - } - - s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) - } - s += "\n" - - if m.meltedPrivateKeySeed != "" { - smelted := "Your private key seed is melted:\n\n%s\n\n" - s += fmt.Sprintf(smelted, m.meltedPrivateKeySeed) - } else { - authorizedPublicKeyText := "Your authorized public key is:\n\n%s\n\n" - s += fmt.Sprintf(authorizedPublicKeyText, m.publicKeyAuthorized) - } - charmIdText := "Your charm id is:\n\n%s\n\n" - s += fmt.Sprintf(charmIdText, m.charmId) - - if m.err != nil { - return m, tea.Quit - } - - s += fmt.Sprintf("\n %s Loading forever... %s\n\n", m.spinner.View(), quitKeys.Help().Desc) - - var wrapAt int - maxWrapMargin := 24 - leastWrapColumnWithMargin := 24 - mostWrapColumnBeforeMaxWrapMargin := 228 - - if m.width < leastWrapColumnWithMargin { - wrapAt = m.width - s = wrap.String(s, wrapAt) - } else { - var wrapAt int - if m.width <= mostWrapColumnBeforeMaxWrapMargin { - wrapAt = m.width - int(1+((m.width-(leastWrapColumnWithMargin+1))*maxWrapMargin)/(mostWrapColumnBeforeMaxWrapMargin-(leastWrapColumnWithMargin+1))) - } else { - wrapAt = m.width - (maxWrapMargin + 1) - } - s = wordwrap.String(s, wrapAt) - } - s = wrap.String(s, m.width) - if m.quitting { - return m, tea.Quit - } - m.viewport.SetContent(s) - - var ( - cmd tea.Cmd - cmds []tea.Cmd - ) - switch msg := msg.(type) { - - case tea.KeyMsg: // todo: super broken, fix this - if key.Matches(msg, quitKeys) { - m.quitting = true - return m, tea.Quit - } - - switch msg.String() { - // The "up" and "k" keys move the cursor up - case "w", "k": - if m.cursor > 0 { - m.cursor-- - } - - // The "down" and "j" keys move the cursor down - case "s", "j": - if m.cursor < len(m.choices)-1 { - m.cursor++ - } - - // The "enter" key and the spacebar (a literal space) toggle - // the selected state for the item that the cursor is pointing at. - case "enter", " ": - _, ok := m.selected[m.cursor] - if ok { - delete(m.selected, m.cursor) - } else { - m.selected[m.cursor] = struct{}{} - } - } - case tea.WindowSizeMsg: - m.height = msg.Height - m.width = msg.Width - if !m.ready { - m.viewport = viewport.New(msg.Width, msg.Height) - m.viewport.KeyMap.Down.SetKeys("down") - m.viewport.KeyMap.Up.SetKeys("up") - m.ready = true - } else { - m.viewport.Width = msg.Width - m.viewport.Height = msg.Height - } - case errMsg: - m.err = msg - default: - m.spinner, cmd = m.spinner.Update(msg) - } - - m.viewport, cmd = m.viewport.Update(msg) - - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m model) View() string { - return m.viewport.View() -} - -var separator = "." -var configuration = koanf.New(separator) -var configurationPaths = []string{ - "./config.kdl", - // identity.kdl identity.config.kdl config.identity.kdl identity.config - // run these against ? binary dir ? pwd of execution ? appdata ? .config ? .local ??? - // then check for further locations/env-prefixes/etc from first pass, rerun on top with second pass - // (maybe config.kdl next to binary sets a new set of configurationPaths, finish out loading from defaults, then load from new paths) - // this pattern continues, after hard-code default env/file search, then custom file/env search, then eventually maybe nats/s3 or other remote or db config -} -var generatedKeyDirPath = ".ssh/generated" -var hostKeyPath = ".ssh/term_info_ed25519" - -func loadConfiguration() { - // lower, replace prefix with identity.", also populate "" and "identity.server." - // IDENTITY_SERVER_* - // lower, replace prefix with "identity.", also populate "" - // IDENTITY_* - // lower, replace prefix with "charm.", also populate "charm.server." - // CHARM_SERVER_* - // lower, replace prefix with "charm." - // CHARM_* - - // example of loading env var - // Load environment variables and merge into the loaded config. - // "MYVAR" is the prefix to filter the env vars by. - // "." is the delimiter used to represent the key hierarchy in env vars. - // The (optional, or can be nil) function can be used to transform - // the env var names, for instance, to lowercase them. - // - // For example, env vars: MYVAR_TYPE and MYVAR_PARENT1_CHILD1_NAME - // will be merged into the "type" and the nested "parent1.child1.name" - // keys in the config file here as we lowercase the key, - // replace `_` with `.` and strip the MYVAR_ prefix so that - // only "parent1.child1.name" remains. - // k.Load(env.Provider("MYVAR_", ".", func(s string) string { - // return strings.Replace(strings.ToLower( - // strings.TrimPrefix(s, "MYVAR_")), "_", ".", -1) - // }), nil) - - // decide order of loading, defaults, env, file, env, file, remotes, etc. - // always overwrite previous? - // ever skip if already set? - - // for file loading: - // maybe. not sure yet, especially for "". - // 1. loading from file as "" - // 2. loading from file as "identity.", put 1 in 2 where not set - // 3. loading from file as "identity.server.", put 2 in 3 where not set - // if i set port=1 and identity.server.port=3, then ""1,"identity"1,"identity.server"3 - // if i set port=1 and identity.port=3, then ""1,"identity"3,"identity.server"3 - for _, path := range configurationPaths { - if _, err := os.Stat(path); err == nil { - if err := configuration.Load(file.Provider(path), kdl.Parser()); err != nil { - log.Error("Failed to load config", "error", err) - } else { - log.Info("Loaded config from file", "path", path) - } - } else { - log.Info("Config not found", "path", path) - } - } -} - -func initializeConfiguration() { - configuration = koanf.New(separator) -} - -func initializeAndLoadConfiguration() { - initializeConfiguration() - log.Debug("Initialized config", "config", configuration.Sprint()) - loadConfiguration() - log.Info("Loaded config", "config", configuration.Sprint()) -} - -func init() { - cobra.OnInitialize(initializeAndLoadConfiguration) - StartAllCmd.AddCommand(StartIdentityCmd) - StartCharmCmd := charmcmd.ServeCmd - StartCharmCmd.Use = "charm" - StartCharmCmd.Aliases = []string{"ch", "c"} - StartAllCmd.AddCommand(StartCharmCmd) - startAllAltCmd := *StartAllCmd - StartAllAltCmd = &startAllAltCmd - StartAllAltCmd.Use = "all" - StartAllAltCmd.Aliases = []string{"al", "a"} - StartAllCmd.AddCommand(StartAllAltCmd) - RootCmd.AddCommand(StartAllCmd) - RootCmd.AddCommand(charmcmd.RootCmd) -} - -var StartCharmCmd = &cobra.Command{} -var StartAllAltCmd = &cobra.Command{} - -var RootCmd = &cobra.Command{ - Use: "identity", - Short: "publish your identity", - Long: `publish your identity and allow others to connect to you.`, -} - -var StartAllCmd = &cobra.Command{ - Use: "start", - Short: "Starts the identity and charm servers", - Run: startAll, - Aliases: []string{"s", "run", "serve", "publish", "pub", "p", "i", "y", "u", "o", "p", "q", "w", "e", "r", "t", "a", "s", "d", "f", "g", "h", "j", "k", "l", "z", "x", "c", "v", "b"}, -} - -var StartIdentityCmd = &cobra.Command{ - Use: "identity", - Short: "Starts only the identity server", - Run: startIdentity, - Aliases: []string{"id", "i"}, -} - func main() { - if err := RootCmd.Execute(); err != nil { + if err := cmd.RootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } - -func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { - pty, _, active := s.Pty() - if !active { - wish.Fatalln(s, "no active terminal, skipping") - return nil, nil - } - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - meltedPrivateKeySeed := s.Context().Permissions().Extensions["private-key-seed-melted"] - m := model{ - spinner: sp, - quitting: false, - err: nil, - term: pty.Term, - width: pty.Window.Width, - height: pty.Window.Height, - meltedPrivateKeySeed: meltedPrivateKeySeed, - choices: []string{"Chat", "Game", "Upload"}, - selected: make(map[int]struct{}), - charmId: s.Context().Permissions().Extensions["charm-id"], - publicKeyAuthorized: s.Context().Permissions().Extensions["public-key-authorized"], - } - return m, []tea.ProgramOption{tea.WithAltScreen()} -} - -func Banner(ctx ssh.Context) string { - return ` -Welcome to the identity server! ("The Service") - -By using The Service, you agree to all of the following terms and conditions. - -The user expressly understands and agrees that developing.today LLC, the operator of The Service, shall not be liable, in law or in equity, to them or to any third party for any direct, indirect, incidental, lost profits, special, consequential, punitive or exemplary damages. - -EACH PARTY MAKES NO WARRANTIES, EXPRESS, IMPLIED OR OTHERWISE, REGARDING ACCURACY, COMPLETENESS OR PERFORMANCE. - -THE SERVICE AND ANY RELATED SERVICES ARE PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS, WITHOUT WARRANTY OF ANY KIND, WHETHER WRITTEN OR ORAL, EXPRESS OR IMPLIED. - -TO THE FULL EXTENT PERMISSIBLE BY LAW, DEVELOPING.TODAY LLC WILL NOT BE LIABLE FOR ANY DAMAGES OF ANY KIND ARISING FROM THE USE OF ANY DEVELOPING.TODAY LLC SERVICE, OR FROM ANY INFORMATION, CONTENT, MATERIALS, PRODUCTS (INCLUDING SOFTWARE) OR OTHER SERVICES INCLUDED ON OR OTHERWISE MADE AVAILABLE TO YOU THROUGH ANY DEVELOPING.TODAY LLC SERVICE, INCLUDING, BUT NOT LIMITED TO DIRECT, INDIRECT, INCIDENTAL, PUNITIVE, AND CONSEQUENTIAL DAMAGES, UNLESS OTHERWISE SPECIFIED IN WRITING. - -TO THE MAXIMUM EXTENT ALLOWED BY LAW, DEVELOPING.TODAY LLC DISCLAIMS ALL WARRANTIES AND REPRESENTATIONS OF ANY KIND, INCLUDING WITHOUT LIMITATION THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT, WHETHER EXPRESS, IMPLIED, OR STATUTORY. DEVELOPING.TODAY LLC PROVIDES NO GUARANTEES THAT THE SERVICES OR NETWORK WILL FUNCTION WITHOUT INTERRUPTION OR ERRORS AND PROVIDES THE NETWORK, SERVICES, AND ANY RELATED CONTENT OR PRODUCTS SUBJECT TO THESE PUBLIC NETWORK TERMS ON AN “AS IS” BASIS. - -By submitting your content (all information you transmit to any developing.today LLC service) ("Your Content") you hereby grant to developing.today LLC an irrevocable, perpetual, royalty-free, worldwide right and license (with right to sublicense) to use, distribute, reproduce, create derivate works of, perform and display Your Content, in whole or part, on or off the any developing.today LLC service for any purpose, commercial or otherwise without acknowledgment, consent or monetary or other compensation to you. - -You hereby represent and warrant that: -- Your Content is an original work created by you -- You have all the rights and consents in and to Your Content necessary to grant the above license -- Your Content does not violate any privacy, publicity or any other applicable laws or regulations -- You understand and agree that developing.today LLC may use any information provided on this form or information available that is associated with your developing.today LLC account to contact you about Your Content. -- You agree that developing.today LLC has no obligation to exercise or exploit the above license. - -If you do not agree to all of the above terms and conditions, then you may not use The Service and must disconnect immediately. -` + fmt.Sprintf("You are using the identity server at %s:%d\n", configuration.String("identity.server.host"), configuration.Int("identity.server.port")) + ` -` + fmt.Sprintf("You are connecting from %s\n", ctx.RemoteAddr().String()) + ` -` + fmt.Sprintf("You are connecting from-with %s\n", ctx.RemoteAddr().Network()) + ` -` + fmt.Sprintf("You are connecting to %s\n", ctx.LocalAddr().String()) + ` -` + fmt.Sprintf("You are connecting to-with %s\n", ctx.LocalAddr().Network()) + ` -` + fmt.Sprintf("Your server version is %s\n", ctx.ServerVersion()) + ` -` + fmt.Sprintf("Your client version is %s\n", ctx.ClientVersion()) + ` -` + fmt.Sprintf("Your session id is %s\n", ctx.SessionID()) + ` -` + fmt.Sprintf("You are connecting with user %s\n", ctx.User()) -} - -func startAll(cmd *cobra.Command, args []string) { - var wg sync.WaitGroup - done := make(chan struct{}) // Channel to signal all tasks are done - - // Helper function for running tasks - runTask := func(taskFunc func(*cobra.Command, []string)) { - defer wg.Done() - - taskFunc(cmd, args) // Execute the task - // After task completion, optionally signal done for cleanup - } - - wg.Add(2) // Prepare for two goroutines - - // Start startCharm in its own goroutine - go runTask(func(cmd *cobra.Command, args []string) { - startCharm(cmd, args) - }) - - // Start startIdentity in its own goroutine - go runTask(func(cmd *cobra.Command, args []string) { - startIdentity(cmd, args) - }) - - go func() { - wg.Wait() // Wait for both tasks to complete - close(done) // Signal that all tasks are done - }() - - // Wait for the done signal before proceeding to cleanup or exit - <-done - fmt.Println("All tasks completed. Proceeding to cleanup and shutdown.") -} - -func startCharm(cmd *cobra.Command, args []string) { - charmcmd.ServeCmdRunE(cmd, args) -} - -func startIdentity(cmd *cobra.Command, args []string) { - connections := auth.NewSafeConnectionMap() - web.GoRunWebServer(connections, configuration) - handler := scp.NewFileSystemHandler("./files") - s, err := wish.NewServer( - wish.WithMiddleware( - scp.Middleware(handler, handler), - bubbletea.Middleware(teaHandler), - comment.Middleware("Thanks, have a nice day!"), - elapsed.Middleware(), - promwish.Middleware("0.0.0.0:9222", "identity"), - logging.Middleware(), - observability.Middleware(connections), - ), - wish.WithPasswordAuth(func(ctx ssh.Context, password string) bool { - log.Info("Accepting password", "password", password, "len", len(password)) - return Connect(ctx, nil, &password, nil, connections) - }), - wish.WithKeyboardInteractiveAuth(func(ctx ssh.Context, challenge gossh.KeyboardInteractiveChallenge) bool { - log.Info("Accepting keyboard interactive") - return Connect(ctx, nil, nil, challenge, connections) - }), - wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { - log.Info("Accepting public key", "publicKeyType", key.Type(), "publicKeyString", base64.StdEncoding.EncodeToString(key.Marshal())) - return Connect(ctx, key, nil, nil, connections) - }), - wish.WithBannerHandler(Banner), - wish.WithAddress(fmt.Sprintf("%s:%d", configuration.String("identity.server.host"), configuration.Int("identity.server.port"))), - wish.WithHostKeyPath(hostKeyPath), - ) - if err != nil { - log.Error("could not start server", "error", err) - return - } - - done := make(chan os.Signal, 1) - signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - log.Info("Starting ssh server", "identity.server.host", configuration.String("identity.server.host"), "identity.server.port", configuration.Int("identity.server.port"), "address", fmt.Sprintf("%s:%d", configuration.String("identity.server.host"), configuration.Int("identity.server.port"))) - go func() { - if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not start server", "error", err) - done <- os.Interrupt - } - }() - - <-done - log.Info("Stopping ssh server", "identity.server.host", configuration.String("identity.server.host"), "identity.server.port", configuration.Int("identity.server.port"), "address", fmt.Sprintf("%s:%d", configuration.String("identity.server.host"), configuration.Int("identity.server.port"))) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { - log.Error("could not stop server", "error", err) - } -} - -type Challenge struct { - Name string - Instruction string - Questions []Question -} - -type Question struct { - Question string - Answer string - HideAnswer bool -} - -func (c Challenge) ExecuteMutable(challenge gossh.KeyboardInteractiveChallenge) ([]string, error) { - var questions []string - var showAnswers []bool - for _, question := range c.Questions { - questions = append(questions, question.Question) - showAnswers = append(showAnswers, !question.HideAnswer) - } - answers, err := challenge(c.Name, c.Instruction, questions, showAnswers) - if err != nil { - return nil, err - } - for i, answer := range answers { - c.Questions[i].Answer = answer - } - return answers, nil -} - -func Connect(ctx ssh.Context, key ssh.PublicKey, password *string, challenge gossh.KeyboardInteractiveChallenge, connections *auth.SafeConnectionMap) bool { - status := "open" - app := "identity" - connectionType := "ssh" - user := ctx.User() - var authMethod string - - if key != nil { - authMethod = "public-key" - } else if password != nil { - authMethod = "password" - } else if challenge != nil { - authMethod = "keyboard-interactive" - } else { - log.Error("No authentication method provided") - return false - } - - if ctx.Permissions().Extensions == nil { - ctx.Permissions().Extensions = make(map[string]string) - } - - var interactive *string - - if challenge != nil { - c := Challenge{ - Name: "Room Challenge:", - Instruction: "Select your room and enter the password if required.", - Questions: []Question{ - { - Question: "What is the room? ", - Answer: "", - }, - { - Question: "What is the password? (leave blank if none, password is sometimes required. Passwords are insecure, passwords may be visible to others.) ", - Answer: "", - HideAnswer: true, - }, - }, - } - _, err := c.ExecuteMutable(challenge) - if err != nil { - log.Error("Failed to get keyboard interactive response", "error", err) - return false - } - ctx.Permissions().Extensions["room"] = c.Questions[0].Answer - password = &c.Questions[1].Answer - - challengesJson, err := json.Marshal(c) - if err != nil { - log.Error("Failed to marshal challenges", "error", err) - return false - } - interactiveStr := string(challengesJson) - interactive = &interactiveStr - - log.Info("Accepting keyboard interactive", "response", interactiveStr, "len", len(interactiveStr)) - } - - var passwordLength *int64 - var passwordHash *string - var passwordHashType *string - var passwordSha256 []byte - var passwordSha256Str string - - if password != nil { - log.Info("Accepting password", "password", *password, "len", len(*password)) - passwordLength = new(int64) - *passwordLength = int64(len(*password)) - hasher := sha256.New() - hasher.Write([]byte(*password)) - passwordSha256 = hasher.Sum(nil) - passwordSha256Str = base64.StdEncoding.EncodeToString(passwordSha256) - passwordHash = &passwordSha256Str - ctx.Permissions().Extensions["password-hash"] = *passwordHash - - passwordHashTypeStr := "sha256" - passwordHashType = &passwordHashTypeStr - ctx.Permissions().Extensions["password-hash-type"] = *passwordHashType - - log.Info("Accepting password", "passwordHash", *passwordHash) - } - - var publicKey *string - var publicKeyType string - - if key != nil { - log.Info("Accepting public key", "publicKeyType", key.Type(), "publicKeyString", base64.StdEncoding.EncodeToString(key.Marshal())) - publicKeyStr := base64.StdEncoding.EncodeToString(key.Marshal()) - publicKey = &publicKeyStr - publicKeyType = key.Type() - ctx.Permissions().Extensions["public-key"] = *publicKey - ctx.Permissions().Extensions["public-key-type"] = publicKeyType - } - var textKeyId *int64 - var hashKeyId *int64 - var ed25519PrivateKey ed25519.PrivateKey - var ed25519PublicKey ed25519.PublicKey - var privateKeyId *int64 - - if publicKey == nil { - log.Info("No public key provided, gathering one") - if password == nil || passwordLength == nil || passwordHash == nil || passwordHashType == nil || passwordSha256 == nil { - log.Error("No public key or password provided", "password", *password, "passwordLength", *passwordLength, "passwordHash", *passwordHash, "passwordHashType", *passwordHashType, "passwordSha256", passwordSha256) - return false - } - - if interactive != nil { - publicKeyStr, err := auth.GetPublicKeyFromText(passwordSha256Str, "%") - if err != nil { - log.Info("Failed to get public key from text", "error", err) - } else { - log.Info("Got public key from text", "publicKeyStr", publicKeyStr) - if publicKeyStr != "" { - out, comment, options, rest, err := gossh.ParseAuthorizedKey([]byte(publicKeyStr)) - if err != nil { - log.Error("Failed to parse public key", "error", err) - return false - } - log.Info("Parsed public key", "out", out, "comment", comment, "options", options, "rest", rest) - publicKey = &publicKeyStr - publicKeyType = out.Type() - - key = out - log.Info("Gathered public key", "publicKey", publicKeyStr) - ctx.Permissions().Extensions["public-key-type"] = publicKeyType - ctx.Permissions().Extensions["public-key-authorized"] = publicKeyStr - log.Info("Setting permissions extensions", "public-key-type", publicKeyType, "public-key-authorized", publicKeyStr, "public-key", publicKeyStr, "public-key-type", publicKeyType) - } - } - } else { - publicKeyStr, err := auth.GetPublicKeyFromHash(passwordSha256Str, "%") - if err != nil { - log.Info("Failed to get public key from hash", "error", err) - } else { - log.Info("Got public key from hash", "publicKeyStr", publicKeyStr) - if publicKeyStr != "" { - out, comment, options, rest, err := gossh.ParseAuthorizedKey([]byte(publicKeyStr)) - if err != nil { - log.Error("Failed to parse public key", "error", err) - return false - } - log.Info("Parsed public key", "out", out, "comment", comment, "options", options, "rest", rest) - publicKey = &publicKeyStr - publicKeyType = out.Type() - - key = out - log.Info("Gathered public key", "publicKey", publicKeyStr) - ctx.Permissions().Extensions["public-key-type"] = publicKeyType - ctx.Permissions().Extensions["public-key-authorized"] = publicKeyStr - log.Info("Setting permissions extensions", "public-key-type", publicKeyType, "public-key-authorized", publicKeyStr, "public-key", publicKeyStr, "public-key-type", publicKeyType) - } - } - } - if key == nil { - log.Info("No public key found, generating one") - if interactive != nil { - ed25519PrivateKey = ed25519.NewKeyFromSeed(passwordSha256) - ed25519PublicKey = ed25519PrivateKey.Public().(ed25519.PublicKey) - } else { - var err error - ed25519PublicKey, ed25519PrivateKey, err = ed25519.GenerateKey(nil) - if err != nil { - log.Error("Failed to generate private key", "error", err) - return false - } - } - log.Info("Generated private key", "pk", ed25519PrivateKey, "pkLen", len(ed25519PrivateKey), "pkStr", base64.StdEncoding.EncodeToString(ed25519PrivateKey)) - - privateKeyIdi, err := auth.InsertPrivateKey(ed25519PrivateKey) - if err != nil { - log.Error("Failed to insert private key", "error", err) - return false - } - privateKeyId = &privateKeyIdi - - log.Info("Generated public key", "pk", ed25519PublicKey, "pkLen", len(ed25519PublicKey), "pkStr", base64.StdEncoding.EncodeToString(ed25519PublicKey), "privateKeyId", *privateKeyId) - ctx.Permissions().Extensions["private-key-seed"] = base64.StdEncoding.EncodeToString(ed25519PrivateKey.Seed()) - ctx.Permissions().Extensions["private-key"] = base64.StdEncoding.EncodeToString(ed25519PrivateKey) - ctx.Permissions().Extensions["private-key-type"] = "ed25519" - ctx.Permissions().Extensions["public-key"] = base64.StdEncoding.EncodeToString(ed25519PublicKey) - ctx.Permissions().Extensions["public-key-type"] = "ed25519" - - sshPubKey, err := gossh.NewPublicKey(ed25519PublicKey) - if err != nil { - log.Fatal("Failed to create SSH public key", err) - } - - if interactive != nil { - textKeyIdi, err := auth.InsertTextPublicKey(passwordSha256Str, "sha256", sshPubKey) - if err != nil { - log.Error("Failed to insert text public key", "error", err) - return false - } - textKeyId = &textKeyIdi - log.Info("Inserted text public key", "textKeyId", *textKeyId) - } else { - hashKeyIdi, err := auth.InsertHashPublicKey(passwordSha256Str, "sha256", sshPubKey) - if err != nil { - log.Error("Failed to insert hash public key", "error", err) - return false - } - hashKeyId = &hashKeyIdi - log.Info("Inserted hash public key", "hashKeyId", *hashKeyId) - } - - authorizedKey := gossh.MarshalAuthorizedKey(sshPubKey) - authKey := string(authorizedKey) - log.Info("Generated public key", "authKey", authKey, "authorizedKey", authorizedKey, "sshPubKey", sshPubKey, "sshPubKeyStr", string(sshPubKey.Marshal())) - ctx.Permissions().Extensions["public-key-authorized"] = authKey - - publicKeyStr := base64.StdEncoding.EncodeToString(authorizedKey) - log.Info("Generated public key", "publicKeyStr", publicKeyStr) - - publicKey = &publicKeyStr - publicKeyType = "ed25519" - log.Info("Generated public key", "publicKey", *publicKey) - parts := strings.Fields(string(authorizedKey)) - if len(parts) < 2 { - log.Fatal("Invalid public key format") - } - keyData, err := base64.StdEncoding.DecodeString(parts[1]) - if err != nil { - log.Fatal("Failed to decode base64 public key", err) - } - log.Info("Generated public key, preparing", "keyData", keyData, "keyDataLen", len(keyData), "parts", parts, "publicKey", *publicKey) - - out, comment, options, rest, err := gossh.ParseAuthorizedKey(authorizedKey) - if err != nil { - log.Fatal("Failed to parse public key", "error", err) - } - log.Info("Parsed public key", "out", out, "comment", comment, "options", options, "rest", rest) - key = out - log.Info("Generated public key", "publicKey", publicKeyStr) - ctx.Permissions().Extensions["public-key"] = *publicKey - ctx.Permissions().Extensions["public-key-type"] = publicKeyType - pkMelted, err := melt.ToMnemonic(&ed25519PrivateKey) - if err != nil { - log.Error("Failed to melt private key", "error", err) - return false - } - ctx.Permissions().Extensions["private-key-seed-melted"] = pkMelted - log.Info("Melted private key", "pkMelted", pkMelted) - } - } else { - log.Info("Public key provided", "publicKey", *publicKey, "key", key, "keyType", key.Type(), "keyMarshal", key.Marshal(), "keyMarshalLen", len(key.Marshal())) - } - - if publicKey == nil { - log.Error("No public key provided") - return false - } - - authorizedKey := gossh.MarshalAuthorizedKey(key) - log.Info("Public key used", "publicKey", authorizedKey) - - serverVersion := ctx.ServerVersion() - clientVersion := ctx.ClientVersion() - sessionHash := ctx.SessionID() - permissionsCriticalOptionsJson, err := json.Marshal(ctx.Permissions().CriticalOptions) - if err != nil { - log.Error("Failed to marshal critical options", "error", err) - return false - } - permissionsCriticalOptions := string(permissionsCriticalOptionsJson) - host := ctx.LocalAddr().String() - port := int64(ctx.LocalAddr().(*net.TCPAddr).Port) - remoteAddr := ctx.RemoteAddr().String() - remoteAddrNetwork := ctx.RemoteAddr().Network() - openedAt := time.Now() - pty := "" - protocol := "ssh" - permissionsExtensions := "" - admin := "" - query := "" - commands := "" - comments := "" - history := "" - - log.Info("Connection opened", "openedAt", openedAt, "remoteAddr", remoteAddr, "remoteAddrNetwork", remoteAddrNetwork, "host", host, "port", port, "serverVersion", serverVersion, "clientVersion", clientVersion, "sessionHash", sessionHash, "permissionsCriticalOptions", permissionsCriticalOptions) - - interactiveStr := "" - if interactive != nil { - interactiveStr = *interactive - } - - connection := auth.Connection{ - Status: &status, - Name: &user, - Description: &user, - App: &app, - AuthMethod: &authMethod, - Type: &connectionType, - Username: &user, - PublicKey: publicKey, - ServerVersion: &serverVersion, - ClientVersion: &clientVersion, - SessionHash: &sessionHash, - PermissionsCriticalOptions: &permissionsCriticalOptions, - PermissionsExtensions: &permissionsExtensions, - Host: &host, - Port: port, - Pty: &pty, - Protocol: &protocol, - RemoteAddr: &remoteAddr, - RemoteAddrNetwork: &remoteAddrNetwork, - OpenedAt: &openedAt, - Interactive: &interactiveStr, - PasswordLength: passwordLength, - PasswordHash: passwordHash, - PasswordHashType: passwordHashType, - Admin: &admin, - Query: &query, - Commands: &commands, - Comments: &comments, - History: &history, - } - - log.Info("Inserting connection", "connection", connection.ToData(), "connectionID", connection.ConnectionID) - connectionID, err := connection.Insert() - - if err != nil { - log.Error("Failed to insert connection", "error", err, "connectionID", connection.ConnectionID) - return false - } - log.Info("Inserted connection", "connectionID", &connectionID, "connection", connection.String(), "connectionID", connection.ConnectionID) - ctx.Permissions().Extensions["connection-id"] = *connectionID - - permissionsExtensionsJson, err := json.Marshal(ctx.Permissions().Extensions) - if err != nil { - log.Error("Failed to marshal extensions", "error", err, "connectionID", connection.ConnectionID) - return false - } - log.Info("Setting permissions extensions", "permissionsExtensions", string(permissionsExtensionsJson), "connectionID", connection.ConnectionID) - connection.SetPermissionsExtensions(string(permissionsExtensionsJson)) - - log.Info("Checking public key", "publicKey", *publicKey, "connectionID", connection.ConnectionID) - result, err := auth.CheckPublicKey(ctx, key) - - log.Info("Checked public key", "result", result, "error", err, "connectionID", connection.ConnectionID) - if err != nil { - var userID int64 - userID, err = auth.InsertUser(ctx) - if err != nil { - log.Error("Failed to insert user", "error", err, "connectionID", connection.ConnectionID) - return false - } - log.Info("Inserted user", "userID", userID, "connectionID", connection.ConnectionID) - - var pk int64 - pk, err = auth.InsertPublicKey(userID, key) - if err != nil { - log.Error("Failed to insert public key", "error", err, "connectionID", connection.ConnectionID) - return false - } - log.Info("Inserted public key", "pk", pk, "connectionID", connection.ConnectionID) - - result, err = auth.CheckPublicKey(ctx, key) - - log.Info("Checked public key", "result", result, "error", err, "connectionID", connection.ConnectionID) - } else { - log.Info("Public key already exists", "result", result, "connectionID", connection.ConnectionID) - } - if err != nil { - log.Error("Failed to check public key", "error", err, "connectionID", connection.ConnectionID) - return false - } - connection.SetCharmID(result.ID) - if ed25519PrivateKey != nil { - affected, err := auth.UpdatePrivateKey(*privateKeyId, &result.ID, connectionID) - if err != nil { - log.Error("Failed to update private key", "error", err, "connectionID", connection.ConnectionID) - return false - } - log.Info("Updated private key", "affected", affected, "connectionID", connection.ConnectionID) - if affected < 1 { - log.Error("Failed to update private key, affected 0", "error", err, "connectionID", connection.ConnectionID) - return false - } - } - if textKeyId != nil { - affected, err := auth.UpdateTextPublicKey(*textKeyId, &result.ID, connectionID) - if err != nil { - log.Error("Failed to update text public key", "error", err, "connectionID", connection.ConnectionID) - return false - } - log.Info("Updated text public key", "affected", affected, "connectionID", connection.ConnectionID) - - if affected < 1 { - log.Error("Failed to update text public key, affected 0", "error", err, "connectionID", connection.ConnectionID) - return false - } - } - if hashKeyId != nil { - affected, err := auth.UpdateHashPublicKey(*hashKeyId, &result.ID, connectionID) - if err != nil { - log.Error("Failed to update hash public key", "error", err, "connectionID", connection.ConnectionID) - return false - } - log.Info("Updated hash public key", "affected", affected, "connectionID", connection.ConnectionID) - if affected < 1 { - log.Error("Failed to update hash public key, affected 0", "error", err, "connectionID", connection.ConnectionID) - return false - } - } - ctx.Permissions().Extensions["charm-id"] = result.ID - connections.Set(*connection.ConnectionID, connection) - ctx.SetValue("connection", connection) - ctx.Permissions().Extensions["charm-name"] = result.Name - log.Info("Setting permissions extensions", "charm-id", result.ID, "charm-name", result.Name, "connectionID", connection.ConnectionID) - jsonRoles, err := json.Marshal(result.Roles) - if err != nil { - log.Error("Failed to marshal roles", "error", err) - return false - } - log.Info("Setting permissions extensions", "charm-roles", string(jsonRoles)) - ctx.Permissions().Extensions["charm-roles"] = string(jsonRoles) - ctx.Permissions().Extensions["charm-created-at"] = result.CreatedAt.Format(time.RFC3339) - ctx.Permissions().Extensions["charm-public-key-created-at"] = result.PublicKeyCreatedAt.Format(time.RFC3339) - ctx.Permissions().Extensions["charm-public-key-type"] = result.PublicKeyType - ctx.Permissions().Extensions["charm-public-key"] = result.PublicKeyString - - log.Info("Setting permissions extensions", "charm-created-at", result.CreatedAt.Format(time.RFC3339), "charm-public-key-created-at", result.PublicKeyCreatedAt.Format(time.RFC3339), "charm-public-key-type", result.PublicKeyType, "charm-public-key", result.PublicKeyString) - - return true -} diff --git a/sources/identity/observability/observability.go b/sources/identity/observability/observability.go index a059d70d..b26acb77 100644 --- a/sources/identity/observability/observability.go +++ b/sources/identity/observability/observability.go @@ -19,7 +19,7 @@ func Middleware(connections *auth.SafeConnectionMap) func(next ssh.Handler) ssh. next(s) status := "closed" conn.Status = &status - connections.Set(s.Context().Permissions().Extensions["connection-id"], conn) + connections.Set(s.Context().Permissions().Extensions["connection-id"], &conn) log.Info("Session ended", "session", s, "sessionID", s.Context().SessionID(), "user", s.Context().User(), "remoteAddr", s.Context().RemoteAddr().String(), "remoteAddrNetwork", s.Context().RemoteAddr().Network(), "localAddr", s.Context().LocalAddr().String(), "localAddrNetwork", s.Context().LocalAddr().Network(), "charm-id", s.Context().Permissions().Extensions["charm-id"], "charm-name", s.Context().Permissions().Extensions["charm-name"], "charm-roles", s.Context().Permissions().Extensions["charm-roles"], "charm-created-at", s.Context().Permissions().Extensions["charm-created-at"], "charm-public-key-created-at", s.Context().Permissions().Extensions["charm-public-key-created-at"], "charm-public-key-type", s.Context().Permissions().Extensions["charm-public-key-type"], "charm-public-key", s.Context().Permissions().Extensions["charm-public-key"], "connection-id", connectionId) } } diff --git a/sources/identity/start-server-all.ps1 b/sources/identity/start-server-all.ps1 index 7744e9ff..f73520fb 100644 --- a/sources/identity/start-server-all.ps1 +++ b/sources/identity/start-server-all.ps1 @@ -1,7 +1,17 @@ #!/usr/bin/env pwsh param( - [switch]$ForceInstallTempl, - [switch]$Update + [switch]$FastBuild, + [switch]$Tidy, + [switch]$SkipBuild, + [switch]$SkipBuildWebJs, + [switch]$SkipBuildTempl, + [switch]$SkipBuildGoGenerate, + [switch]$SkipBuildGoModTidy, + [switch]$SkipBuildGoGet, + [switch]$SkipBuildGoBuild, + [switch]$SkipBuildGoExperiment, + [switch]$Update, + [switch]$ForceInstallTempl ) Set-StrictMode -Version Latest @@ -17,9 +27,25 @@ if ($PSNativeCommandUseErrorActionPreference) { $originalVerbosePreference = $VerbosePreference $VerbosePreference = 'Continue' +Write-Verbose "script: $($MyInvocation.MyCommand.Name)" +Write-Verbose "psscriptroot: $PSScriptRoot" +Write-Verbose "full script path: $PSScriptRoot$([IO.Path]::DirectorySeparatorChar)$($MyInvocation.MyCommand.Name)" Write-Verbose "originalVerbosePreference: $originalVerbosePreference" Write-Verbose "VerbosePreference: $VerbosePreference" +if ($FastBuild) { + $SkipBuildWebJs = $true + $SkipBuildTempl = $true + $SkipBuildGoGenerate = $true + $SkipBuildGoModTidy = $true + $SkipBuildGoGet = $true + $SkipBuildGoExperiment = $true +} + +if ($Tidy) { + $SkipBuildGoModTidy = $false +} + try { $cwd = Get-Location @@ -32,8 +58,13 @@ try { Set-Location $PSScriptRoot - ."$PSScriptRoot/build-libsql.ps1" -ForceInstallTempl:$ForceInstallTempl -Update:$Update - + if (-not $SkipBuild) { + Write-Verbose "Building libsql" + ."$PSScriptRoot/build-libsql.ps1" -ForceInstallTempl:$ForceInstallTempl -Update:$Update -SkipBuildWebJs:$SkipBuildWebJs -SkipBuildTempl:$SkipBuildTempl -SkipBuildGoGenerate:$SkipBuildGoGenerate -SkipBuildGoModTidy:$SkipBuildGoModTidy -SkipBuildGoGet:$SkipBuildGoGet -SkipBuildGoBuild:$SkipBuildGoBuild -SkipBuildGoExperiment:$SkipBuildGoExperiment + } + else { + Write-Verbose "Skipping libsql build" + } $env:CHARM_SERVER_DB_DRIVER = "libsql" if ([string]::IsNullOrEmpty($env:TURSO_HOST)) { diff --git a/sources/identity/start-server-charm-migrate.ps1 b/sources/identity/start-server-charm-migrate.ps1 index 7744e9ff..f73520fb 100644 --- a/sources/identity/start-server-charm-migrate.ps1 +++ b/sources/identity/start-server-charm-migrate.ps1 @@ -1,7 +1,17 @@ #!/usr/bin/env pwsh param( - [switch]$ForceInstallTempl, - [switch]$Update + [switch]$FastBuild, + [switch]$Tidy, + [switch]$SkipBuild, + [switch]$SkipBuildWebJs, + [switch]$SkipBuildTempl, + [switch]$SkipBuildGoGenerate, + [switch]$SkipBuildGoModTidy, + [switch]$SkipBuildGoGet, + [switch]$SkipBuildGoBuild, + [switch]$SkipBuildGoExperiment, + [switch]$Update, + [switch]$ForceInstallTempl ) Set-StrictMode -Version Latest @@ -17,9 +27,25 @@ if ($PSNativeCommandUseErrorActionPreference) { $originalVerbosePreference = $VerbosePreference $VerbosePreference = 'Continue' +Write-Verbose "script: $($MyInvocation.MyCommand.Name)" +Write-Verbose "psscriptroot: $PSScriptRoot" +Write-Verbose "full script path: $PSScriptRoot$([IO.Path]::DirectorySeparatorChar)$($MyInvocation.MyCommand.Name)" Write-Verbose "originalVerbosePreference: $originalVerbosePreference" Write-Verbose "VerbosePreference: $VerbosePreference" +if ($FastBuild) { + $SkipBuildWebJs = $true + $SkipBuildTempl = $true + $SkipBuildGoGenerate = $true + $SkipBuildGoModTidy = $true + $SkipBuildGoGet = $true + $SkipBuildGoExperiment = $true +} + +if ($Tidy) { + $SkipBuildGoModTidy = $false +} + try { $cwd = Get-Location @@ -32,8 +58,13 @@ try { Set-Location $PSScriptRoot - ."$PSScriptRoot/build-libsql.ps1" -ForceInstallTempl:$ForceInstallTempl -Update:$Update - + if (-not $SkipBuild) { + Write-Verbose "Building libsql" + ."$PSScriptRoot/build-libsql.ps1" -ForceInstallTempl:$ForceInstallTempl -Update:$Update -SkipBuildWebJs:$SkipBuildWebJs -SkipBuildTempl:$SkipBuildTempl -SkipBuildGoGenerate:$SkipBuildGoGenerate -SkipBuildGoModTidy:$SkipBuildGoModTidy -SkipBuildGoGet:$SkipBuildGoGet -SkipBuildGoBuild:$SkipBuildGoBuild -SkipBuildGoExperiment:$SkipBuildGoExperiment + } + else { + Write-Verbose "Skipping libsql build" + } $env:CHARM_SERVER_DB_DRIVER = "libsql" if ([string]::IsNullOrEmpty($env:TURSO_HOST)) { diff --git a/sources/identity/start-server-charm.ps1 b/sources/identity/start-server-charm.ps1 index 7744e9ff..f73520fb 100644 --- a/sources/identity/start-server-charm.ps1 +++ b/sources/identity/start-server-charm.ps1 @@ -1,7 +1,17 @@ #!/usr/bin/env pwsh param( - [switch]$ForceInstallTempl, - [switch]$Update + [switch]$FastBuild, + [switch]$Tidy, + [switch]$SkipBuild, + [switch]$SkipBuildWebJs, + [switch]$SkipBuildTempl, + [switch]$SkipBuildGoGenerate, + [switch]$SkipBuildGoModTidy, + [switch]$SkipBuildGoGet, + [switch]$SkipBuildGoBuild, + [switch]$SkipBuildGoExperiment, + [switch]$Update, + [switch]$ForceInstallTempl ) Set-StrictMode -Version Latest @@ -17,9 +27,25 @@ if ($PSNativeCommandUseErrorActionPreference) { $originalVerbosePreference = $VerbosePreference $VerbosePreference = 'Continue' +Write-Verbose "script: $($MyInvocation.MyCommand.Name)" +Write-Verbose "psscriptroot: $PSScriptRoot" +Write-Verbose "full script path: $PSScriptRoot$([IO.Path]::DirectorySeparatorChar)$($MyInvocation.MyCommand.Name)" Write-Verbose "originalVerbosePreference: $originalVerbosePreference" Write-Verbose "VerbosePreference: $VerbosePreference" +if ($FastBuild) { + $SkipBuildWebJs = $true + $SkipBuildTempl = $true + $SkipBuildGoGenerate = $true + $SkipBuildGoModTidy = $true + $SkipBuildGoGet = $true + $SkipBuildGoExperiment = $true +} + +if ($Tidy) { + $SkipBuildGoModTidy = $false +} + try { $cwd = Get-Location @@ -32,8 +58,13 @@ try { Set-Location $PSScriptRoot - ."$PSScriptRoot/build-libsql.ps1" -ForceInstallTempl:$ForceInstallTempl -Update:$Update - + if (-not $SkipBuild) { + Write-Verbose "Building libsql" + ."$PSScriptRoot/build-libsql.ps1" -ForceInstallTempl:$ForceInstallTempl -Update:$Update -SkipBuildWebJs:$SkipBuildWebJs -SkipBuildTempl:$SkipBuildTempl -SkipBuildGoGenerate:$SkipBuildGoGenerate -SkipBuildGoModTidy:$SkipBuildGoModTidy -SkipBuildGoGet:$SkipBuildGoGet -SkipBuildGoBuild:$SkipBuildGoBuild -SkipBuildGoExperiment:$SkipBuildGoExperiment + } + else { + Write-Verbose "Skipping libsql build" + } $env:CHARM_SERVER_DB_DRIVER = "libsql" if ([string]::IsNullOrEmpty($env:TURSO_HOST)) { diff --git a/sources/identity/start-server-identity.ps1 b/sources/identity/start-server-identity.ps1 index 7744e9ff..f73520fb 100644 --- a/sources/identity/start-server-identity.ps1 +++ b/sources/identity/start-server-identity.ps1 @@ -1,7 +1,17 @@ #!/usr/bin/env pwsh param( - [switch]$ForceInstallTempl, - [switch]$Update + [switch]$FastBuild, + [switch]$Tidy, + [switch]$SkipBuild, + [switch]$SkipBuildWebJs, + [switch]$SkipBuildTempl, + [switch]$SkipBuildGoGenerate, + [switch]$SkipBuildGoModTidy, + [switch]$SkipBuildGoGet, + [switch]$SkipBuildGoBuild, + [switch]$SkipBuildGoExperiment, + [switch]$Update, + [switch]$ForceInstallTempl ) Set-StrictMode -Version Latest @@ -17,9 +27,25 @@ if ($PSNativeCommandUseErrorActionPreference) { $originalVerbosePreference = $VerbosePreference $VerbosePreference = 'Continue' +Write-Verbose "script: $($MyInvocation.MyCommand.Name)" +Write-Verbose "psscriptroot: $PSScriptRoot" +Write-Verbose "full script path: $PSScriptRoot$([IO.Path]::DirectorySeparatorChar)$($MyInvocation.MyCommand.Name)" Write-Verbose "originalVerbosePreference: $originalVerbosePreference" Write-Verbose "VerbosePreference: $VerbosePreference" +if ($FastBuild) { + $SkipBuildWebJs = $true + $SkipBuildTempl = $true + $SkipBuildGoGenerate = $true + $SkipBuildGoModTidy = $true + $SkipBuildGoGet = $true + $SkipBuildGoExperiment = $true +} + +if ($Tidy) { + $SkipBuildGoModTidy = $false +} + try { $cwd = Get-Location @@ -32,8 +58,13 @@ try { Set-Location $PSScriptRoot - ."$PSScriptRoot/build-libsql.ps1" -ForceInstallTempl:$ForceInstallTempl -Update:$Update - + if (-not $SkipBuild) { + Write-Verbose "Building libsql" + ."$PSScriptRoot/build-libsql.ps1" -ForceInstallTempl:$ForceInstallTempl -Update:$Update -SkipBuildWebJs:$SkipBuildWebJs -SkipBuildTempl:$SkipBuildTempl -SkipBuildGoGenerate:$SkipBuildGoGenerate -SkipBuildGoModTidy:$SkipBuildGoModTidy -SkipBuildGoGet:$SkipBuildGoGet -SkipBuildGoBuild:$SkipBuildGoBuild -SkipBuildGoExperiment:$SkipBuildGoExperiment + } + else { + Write-Verbose "Skipping libsql build" + } $env:CHARM_SERVER_DB_DRIVER = "libsql" if ([string]::IsNullOrEmpty($env:TURSO_HOST)) { diff --git a/sources/identity/web/handlers.go b/sources/identity/web/handlers.go index 03445515..0b33641f 100644 --- a/sources/identity/web/handlers.go +++ b/sources/identity/web/handlers.go @@ -63,6 +63,26 @@ func showIDAPIHandler(w http.ResponseWriter, r *http.Request) { htmx.NewResponse().Write(w) log.Info("request API", "method", r.Method, "status", http.StatusOK, "path", r.URL.Path) } +func showIDAPIJsonHandler(w http.ResponseWriter, r *http.Request) { + connections, ok := auth.GetConnectionMap(r.Context()) + if !ok { + http.Error(w, "Could not get connections from context", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + jsonConnections, err := connections.JSON() + + if err != nil { + http.Error(w, "Could not get JSON for connections", http.StatusInternalServerError) + return + } + + w.Write([]byte(jsonConnections)) + log.Info("request API", "method", r.Method, "status", http.StatusOK, "path", r.URL.Path) +} func renderConnectionRow(c *auth.Connection) string { html, err := c.HTML() diff --git a/sources/identity/web/server.go b/sources/identity/web/server.go index e01e048d..a8949c6d 100644 --- a/sources/identity/web/server.go +++ b/sources/identity/web/server.go @@ -1,18 +1,29 @@ package web import ( + "context" "embed" + "encoding/json" "fmt" "net/http" + "net/url" "os" - "strconv" + "strings" "time" + "crypto/ed25519" + + "github.com/auth0/go-jwt-middleware/v2/jwks" "github.com/charmbracelet/log" "github.com/developing-today/code/src/identity/auth" - "github.com/go-chi/chi/v5" // replace with http ? - "github.com/go-chi/chi/v5/middleware" // ??? + "github.com/developing-today/code/src/identity/configuration" // replace with http ? + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + + // ??? + "github.com/golang-jwt/jwt" "github.com/knadh/koanf" + "gopkg.in/go-jose/go-jose.v2" gowebly "github.com/gowebly/helpers" ) @@ -22,154 +33,468 @@ var static embed.FS // todo make a input struct for webserver and use opts pattern -func RunWebServer(connections *auth.SafeConnectionMap, configuration *koanf.Koanf) { - if err := RunServer(connections, configuration); err != nil { +func RunWebServer(connections *auth.SafeConnectionMap, config *koanf.Koanf) { + if err := RunServer(connections, config); err != nil { log.Error("Failed to start server!", "details", err.Error()) os.Exit(1) } } -func GoRunWebServer(connections *auth.SafeConnectionMap, configuration *koanf.Koanf) { - go RunWebServer(connections, configuration) +func GoRunWebServer(connections *auth.SafeConnectionMap, configuration *configuration.IdentityServerConfiguration) { + go RunWebServer(connections, configuration.Configuration) } -// runServer runs a new HTTP server with the loaded environment variables. -func RunServer(connections *auth.SafeConnectionMap, configuration *koanf.Koanf) error { +func Strings(config *koanf.Koanf, key string) []string { + stringArray := config.Strings(key) + if len(stringArray) > 0 { + return stringArray + } + stringValue := config.String(key) + + if stringValue == "" { + log.Error("Empty string value", "key", key) + return []string{} + } - // jwtMiddleware, err := setupJWTMiddleware(configuration) - // if err != nil { - // log.Error("Failed to set up JWT middleware!", "details", err.Error()) - // return err - // } + return []string{stringValue} +} + +func RunServer(connections *auth.SafeConnectionMap, config *koanf.Koanf) error { + jwksURLString := config.String("identity.server.jwt.jwks") + log.Info("JWKS URL", "jwks", jwksURLString) + jwksURL, err := url.Parse(jwksURLString) + if err != nil { + return fmt.Errorf("failed to parse JWKS URL: %v", err) + } + log.Info("JWKS URL", "jwks", jwksURL) + issuer := config.String("identity.server.jwt.issuer") + log.Info("Issuer", "issuer", issuer) + issuerURL, err := url.Parse(issuer) + if err != nil { + return fmt.Errorf("failed to parse issuer URL: %v", err) + } + log.Info("Issuer URL", "issuer", issuerURL) + audience := Strings(config, "identity.server.jwt.audience") + log.Info("Audience List", "audience", audience) + if len(audience) == 0 { + return fmt.Errorf("audience list is empty") + } + cacheTTL := config.Duration("identity.server.jwt.cache_ttl") + log.Info("Cache TTL from config", "cacheTTL", cacheTTL) + if cacheTTL == 0 { + cacheTTL = 15 * time.Minute + } + log.Info("Cache TTL", "cacheTTL", cacheTTL) - port, err := strconv.Atoi(gowebly.Getenv("BACKEND_PORT", "7000")) + keyFunc, err := NewJWKSValidator(jwksURL, issuerURL, audience, cacheTTL) if err != nil { + log.Error("Failed to create JWKS validator", "error", err) return err } - router := chi.NewRouter() + + webPort := config.Int("identity.server.web.port") + log.Info("Web Port", "port", webPort) + if webPort == 0 { + webPort = 7000 + log.Info("Using default Web Port", "port", webPort) + } + + authorizationCookieName := config.String("identity.server.authorization.cookie_name") + log.Info("Cookie Name", "cookieName", authorizationCookieName) + + if authorizationCookieName == "" { + authorizationCookieName = "Authorization" + log.Info("Using default Cookie Name", "cookieName", authorizationCookieName) + } + authorizationHeaderName := config.String("identity.server.authorization.header_name") + log.Info("Header Name", "headerName", authorizationHeaderName) + if authorizationHeaderName == "" { + authorizationCookieName = "Authorization" + log.Info("Using default Header Name", "headerName", authorizationHeaderName) + } + authorizationHeaderPrefix := config.String("identity.server.authorization.header_prefix") + log.Info("Header Prefix", "headerPrefix", authorizationHeaderPrefix) + if authorizationHeaderPrefix == "" { + authorizationHeaderPrefix = "Bearer" + log.Info("Using default Header Prefix", "headerPrefix", authorizationHeaderPrefix) + } + + router := chi.NewRouter() // todo try http.NewServeMux if they add a use, with, route router.Use(ConnectionsMiddleware(connections)) router.Use(middleware.Logger) router.Handle("/static/*", gowebly.StaticFileServerHandler(http.FS(static))) router.Get("/admin/connections", indexViewHandler) - // router.With(jwtMiddleware.CheckJWT).Get("/admin/api/id", showIDAPIHandler) - router.Post("/admin/api/id", showIDAPIHandler) - - router.Post("/set-cookie", setCookieHandler) - router.Get("/invalidate-cookie", invalidateCookieHandler) + router.With( + CookieMiddleware(authorizationCookieName, keyFunc), + ).Post("/admin/api/id", showIDAPIHandler) + router.With( + BearerMiddleware(keyFunc, authorizationHeaderName, authorizationHeaderPrefix), + ).Post( + "/admin/rest/id", + showIDAPIJsonHandler) + router.Post("/set-cookie", setCookieHandler(keyFunc, authorizationCookieName)) + router.Get("/invalidate-cookie", invalidateCookieHandler(authorizationCookieName)) server := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: router, // handle all chi routes + Addr: fmt.Sprintf(":%d", webPort), + Handler: router, ReadTimeout: 10 * time.Second, WriteTimeout: 20 * time.Second, } - log.Info("Starting web server...", "port", port) - + log.Info("Starting web server...", "webPort", webPort) return server.ListenAndServe() } +func NewJWKSValidator(jwksURL *url.URL, issuer *url.URL, audience []string, cacheTTL time.Duration) (jwt.Keyfunc, error) { + jwksClient := jwks.NewCachingProvider(issuer, cacheTTL, jwks.WithCustomJWKSURI(jwksURL)) // probably find a way to remove custom jwks + log.Info("JWKS Validator", "jwksClientCacheTTL", cacheTTL, "jwksURL", jwksURL, "issuer", issuer, "audience", audience, "jwksClientCustomJWKSURI", jwksClient.CustomJWKSURI, "jwksClientIssuerURL", jwksClient.IssuerURL) + + return func(token *jwt.Token) (interface{}, error) { + log.Info("Validating token", "token", token) + + kid, ok := token.Header["kid"].(string) + if !ok { + log.Error("Expecting JWT header to have string 'kid'") + return nil, fmt.Errorf("expecting JWT header to have string 'kid'") + } + log.Info("Key ID", "kid", kid) + + cacheSetInterface, err := jwksClient.KeyFunc(context.Background()) + if err != nil { + log.Error("Failed to get JWKS from cache", "error", err) + return nil, fmt.Errorf("failed to get JWKS from cache: %w", err) + } + log.Info("Cache Set", "cacheSet", cacheSetInterface, "type", fmt.Sprintf("%T", cacheSetInterface), "convertedCacheSet", cacheSetInterface.(*jose.JSONWebKeySet)) + + cacheSet, ok := cacheSetInterface.(*jose.JSONWebKeySet) + if !ok { + log.Error("Failed to convert cache set to *jose.JSONWebKeySet") + return nil, fmt.Errorf("failed to convert cache set to *jose.JSONWebKeySet") + } + log.Info("Cache Set", "cacheSet", cacheSet) + + cacheKeys := cacheSet.Key(kid) + + if len(cacheKeys) == 0 { + log.Error("Key not found in cache", "kid", kid) + return nil, fmt.Errorf("key %v not found in cache", kid) + } + + log.Info("Cache Keys", "cacheKeys", cacheKeys) + + cacheKey := cacheKeys[0] + log.Info("Cache Key", "cacheKey", cacheKey) + + if cacheKey.KeyID != kid { // todo allow multiple keys + log.Error("Key ID does not match", "kid", kid, "keyID", cacheKey.KeyID, "cacheKey", cacheKey) + return nil, fmt.Errorf("key ID does not match") + } + if !cacheKey.Valid() { + log.Error("Key is not valid", "cacheKey", cacheKey) + return nil, fmt.Errorf("key is not valid") + } + + aud := token.Claims.(jwt.MapClaims)["aud"] + log.Info("Audience", "aud", aud, "audience", audience) + + if !audienceContainsAll(aud.([]interface{}), audience) { + log.Error("Invalid audience", "aud", aud, "audience", audience) + return nil, fmt.Errorf("invalid audience, expected %v, got %v", audience, aud) + } + + switch key := cacheKey.Key.(type) { + case ed25519.PublicKey: + log.Info("Key", "key", key, "type", "ed25519.PublicKey") + return key, nil + default: + log.Error("Key is not a valid type", "key", key, "type", fmt.Sprintf("%T", key), "expectedType", []string{"ed25519.PublicKey"}) + return nil, fmt.Errorf("key is not a valid type, expected %v, got %v", []string{"ed25519.PublicKey"}, fmt.Sprintf("%T", key)) + } + }, nil +} + +func audienceContains(aud string, audience []interface{}) bool { + for _, a := range audience { + if a == aud { + log.Info("Audience contains", "aud", a, "audience", audience) + return true + } + } + log.Error("Audience does not contain", "aud", aud, "audience", audience) + return false +} + +func audienceContainsAll(aud []interface{}, audience []string) bool { + for _, a := range audience { + if !audienceContains(a, aud) { + log.Error("Audience does not contain", "aud", a, "audience", aud) + return false + } + } + log.Info("Audience contains all", "audience", aud, "expectedAudience", audience) + return true +} + func ExpireCookie(token string) error { - // expire the cookie in the database + // expire the cookie & close the connection in the database + // close the connection in the cache return nil } -func invalidateCookieHandler(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie("token") +func invalidateCookieHandler(cookieName string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(cookieName) - if err != nil { - w.Write([]byte("No cookie found")) - return + if err != nil { + w.Write([]byte("No cookie found")) + return + } + log.Info("Cookie found", "cookie", cookie) + + err = ExpireCookie(cookie.Value) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + emptyCookie := &http.Cookie{ + Name: cookieName, + Value: "", + Expires: time.Unix(0, 0), + HttpOnly: true, + MaxAge: -1, + } + + http.SetCookie(w, emptyCookie) + w.Write([]byte("Cookie invalidated")) } - log.Printf("Cookie found: %s", cookie.Value) +} +func validateCookie(cookieName string) func(r *http.Request) (bool, error) { + return func(r *http.Request) (bool, error) { + cookie, err := r.Cookie(cookieName) + if err != nil { + return false, err + } + log.Info("Cookie found", "cookie", cookie) + // validate the cookie in the cache, else the database, if found, return true, else false and error (is it found but expired?) - err = ExpireCookie(cookie.Value) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return + return true, nil } +} - emptyCookie := &http.Cookie{ - Name: "token", - Value: "", - Expires: time.Unix(0, 0), - HttpOnly: true, - MaxAge: -1, +func parseToken(token string, keyFunc jwt.Keyfunc) (bool, error) { + // validate the token in the cache, else the database, if found, return true, else false and error (is it found but expired?) + if token == "" { + log.Error("No token found") + return false, nil } + token = strings.Replace(token, "Bearer ", "", 1) + token = strings.TrimSpace(token) + token = strings.Trim(token, "'\"") - http.SetCookie(w, emptyCookie) - w.Write([]byte("Cookie invalidated")) -} + parsed, err := jwt.Parse(token, keyFunc) -func validateCookie(r *http.Request) (bool, error) { - cookie, err := r.Cookie("token") if err != nil { + log.Error("Error parsing token", "error", err) + jsonToken, err := json.Marshal(token) + if err != nil { + log.Error("Error parsing token", "error", err) + } + log.Error("Token: ", "token", string(jsonToken)) return false, err } - log.Printf("Cookie found: %s", cookie.Value) - // validate the cookie in the cache, else the database, if found, return true, else false and error (is it found but expired?) - return true, nil -} -func parseToken(token string) (bool, error) { //todo: return jwt.Token, error - // validate the token in the cache, else the database, if found, return true, else false and error (is it found but expired?) - if token == "" { + logJWT(parsed) + + if !parsed.Valid { return false, nil } + claims, ok := parsed.Claims.(jwt.MapClaims) + + if !ok { + return false, fmt.Errorf("invalid claims") + } + + log.Info("Claims", "claims", claims) + return true, nil } -func NewTokenConnection(token string) (*auth.Connection, error) { +func NewTokenConnection(token string, keyFunc jwt.Keyfunc) (*auth.Connection, error) { + // validate the token in the cache, else the database, if found, return true, else false and error (is it found but expired?) + if token == "" { + return nil, nil + } - validateToken, err := parseToken(token) + parsed, err := jwt.Parse(token, keyFunc) + + logJWT(parsed) if err != nil { + log.Error("Error parsing token", "error", err) return nil, err } - if !validateToken { - return nil, nil + claims, ok := parsed.Claims.(jwt.MapClaims) + + if !ok { + log.Error("Invalid claims") + return nil, fmt.Errorf("invalid claims") } + log.Info("Claims", "claims", claims) + connection := &auth.Connection{} connection.Insert() + log.Info("Connection created", "connection", connection) + return connection, nil } -func setCookieHandler(w http.ResponseWriter, r *http.Request) { +func setCookieHandler(keyFunc jwt.Keyfunc, authorizationCookieName string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { - validateCookie, err := validateCookie(r) + // todo check current cookie first, if it exists, return error - if err == nil && validateCookie { - w.WriteHeader(http.StatusNoContent) - return + log.Info("Request", "request", r) + token := r.FormValue(authorizationCookieName) + if token == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + log.Error("No token found") + return + } + log.Info("Token", "token", token) + + validToken, err := parseToken(token, keyFunc) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + log.Error("Error parsing token", "error", err) + return + } + log.Info("Valid token", "validToken", validToken) + + connection, err := NewTokenConnection(token, keyFunc) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + log.Error("Error creating connection", "error", err) + return + } + + if connection == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + log.Error("No connection found") + return + } + log.Info("Connection created", "connection", connection) + + cookie := &http.Cookie{ + Name: authorizationCookieName, + Value: token, + Expires: time.Now().Add(24 * time.Hour), + HttpOnly: true, + } + + http.SetCookie(w, cookie) + w.Write([]byte("Cookie set")) + log.Info("Cookie set", "token", token, "cookie", cookie, "connection", connection) } +} - token := r.FormValue("token") - log.Printf("Received token: %s", token) +// BearerMiddleware extracts the JWT token from the Authorization header and delegates to JWTMiddleware. +func BearerMiddleware(keyFunc jwt.Keyfunc, authorizationHeaderName string, authorizationHeaderPrefix string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get(authorizationHeaderName) + if authHeader == "" { + http.Error(w, "Authorization header is required", http.StatusUnauthorized) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || strings.ToLower(parts[0]) != strings.ToLower(authorizationHeaderPrefix) { + http.Error(w, "Authorization header format is invalid", http.StatusUnauthorized) + return + } + + token := parts[1] + JWTMiddleware(token, keyFunc)(next).ServeHTTP(w, r) + }) + } +} + +// CookieMiddleware extracts the JWT token from a specified cookie and delegates to JWTMiddleware. +func CookieMiddleware(cookieName string, keyFunc jwt.Keyfunc) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(cookieName) + if err != nil { + http.Error(w, "Cookie is required", http.StatusUnauthorized) + return + } + + token := cookie.Value + JWTMiddleware(token, keyFunc)(next).ServeHTTP(w, r) + }) + } +} - connection, err := NewTokenConnection(token) +// JWTMiddleware performs JWT validation and logs the token details. +func JWTMiddleware(token string, keyFunc jwt.Keyfunc) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, err := jwt.Parse(token, keyFunc) + if err != nil { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + if !token.Valid { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + logJWT(token) + + ctx := context.WithValue(r.Context(), "claims", token.Claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// logJWT logs the JWT header, payload (claims), and signature. +func logJWT(token *jwt.Token) { + headerJson, err := json.Marshal(token.Header) if err != nil { - w.WriteHeader(http.StatusInternalServerError) + log.Error("Failed to parse JWT header") return } + log.Info("", "JWT Header", string(headerJson)) - if connection == nil { - w.WriteHeader(http.StatusUnauthorized) + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + log.Error("Failed to parse JWT claims") return } + log.Info("", "JWT Claims", claims) - w.WriteHeader(http.StatusNoContent) + signatureJson, err := json.Marshal(token.Signature) + if err != nil { + log.Error("Failed to parse JWT signature") + return + } + log.Info("", "JWT Signature", string(signatureJson)) - http.SetCookie(w, &http.Cookie{ - Name: "token", - Value: "validationToken", - Expires: time.Now().Add(24 * time.Hour), - HttpOnly: true, - }) + method, err := json.Marshal(token.Method) + if err != nil { + log.Error("Failed to parse JWT method") + return + } + log.Info("", "JWT Method", string(method)) + + log.Info("", "JWT Valid", token.Valid) } func ConnectionsMiddleware(connections *auth.SafeConnectionMap) func(next http.Handler) http.Handler { @@ -181,50 +506,17 @@ func ConnectionsMiddleware(connections *auth.SafeConnectionMap) func(next http.H } } -func JwtValidationMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tokenString := r.Header.Get("Authorization") - // tokenString := extractToken(r) // Implement this function to extract the JWT from the request - if tokenString == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } +func JwtValidationMiddleware(cookieName string) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get(cookieName) - // Use the previously shown validateJWT function - // valid, err := validateJWT(tokenString, "https://yourdomain.com/.well-known/jwks.json") - // if err != nil || !valid { - // http.Error(w, "Unauthorized", http.StatusUnauthorized) - // return - // } - - // Token is valid; proceed with the request - next.ServeHTTP(w, r) - }) -} - -// func setupJWTMiddleware(configuration *koanf.Koanf) (*jwtmiddleware.JWTMiddleware, error) { -// issuer := configuration.String("identity.server.jwt.issuer") -// audience := []string{configuration.String("identity.server.jwt.audience")} - -// issuerURL, err := url.Parse(issuer) -// if err != nil { -// return nil, fmt.Errorf("failed to parse the issuer URL: %v", err) -// } - -// provider := jwks.NewCachingProvider(issuerURL, 15*time.Minute) - -// jwtValidator, err := validator.New( -// provider.KeyFunc, -// // ecdsa, -// validator. -// issuerURL.String(), -// audience, -// ) -// if err != nil { -// log.Error("Failed to set up the validator!", "details", err.Error()) -// return nil, fmt.Errorf("failed to set up the validator: %v", err) -// } -// log.Info("JWT middleware set up successfully") - -// return jwtmiddleware.New(jwtValidator.ValidateToken), nil -// } + if token == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/sources/identity/web/templates/pages/index.templ b/sources/identity/web/templates/pages/index.templ index e73967c4..a373ef39 100644 --- a/sources/identity/web/templates/pages/index.templ +++ b/sources/identity/web/templates/pages/index.templ @@ -22,7 +22,7 @@ templ BodyContent(h1, text string) { Get the connections on your server.

- +

diff --git a/sources/identity/web/templates/pages/index_templ.go b/sources/identity/web/templates/pages/index_templ.go index 850ac242..dbc3cf75 100644 --- a/sources/identity/web/templates/pages/index_templ.go +++ b/sources/identity/web/templates/pages/index_templ.go @@ -94,7 +94,7 @@ func BodyContent(h1, text string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">Get the connections on your server.


Do more with SSH.

developing.todaynewscode

© developing.today LLC

") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">Get the connections on your server.


Do more with SSH.

developing.todaynewscode

© developing.today LLC

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }