Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2a741b7
feat(secrets): generate private ssh key on server
fiftin Feb 21, 2026
186b1b8
feat(secrets): gen SSH key on server
fiftin Feb 21, 2026
3463fbb
fix(secrets): show public key for updated secrets too
fiftin Feb 21, 2026
1fc0245
feat(secrets): don't allow user override plain field
fiftin Feb 21, 2026
aaecddd
Merge branch 'develop' into feat/gen_ssh_key
fiftin May 2, 2026
1d638a4
Merge branch 'develop' into feat/gen_ssh_key
fiftin May 30, 2026
e1672e7
fix: merge conflict
fiftin Aug 19, 2026
a17659f
fix(ui): linter
fiftin Aug 20, 2026
f12be5c
fix(secrets): sql request args
fiftin Aug 20, 2026
e77f70e
fix(ui): disable input if not required
fiftin Aug 25, 2026
191cc0a
fix(be): remove unused field from API model
fiftin Aug 25, 2026
f6a23e7
Merge branch 'develop' into feat/gen_ssh_key
fiftin Sep 7, 2026
fd84159
Merge branch 'develop' into feat/gen_ssh_key
fiftin Sep 7, 2026
9582838
fix(secrets): auto gen ssh key with public key
fiftin Sep 7, 2026
e124444
fix(ui): extra space
fiftin Sep 7, 2026
0e8aa59
feat(secrets): public key correct format
fiftin Sep 7, 2026
8a92094
fix(ci): avoid release tool installs in image builds
Copilot Sep 7, 2026
7a37f6f
fix(keys): preserve generated ssh key flows
Copilot Sep 7, 2026
2d0b348
Fix formatting of SSH key assignment
fiftin Sep 10, 2026
eb6f356
fix: persist generated ssh key on update
Copilot Sep 10, 2026
5942665
Merge branch 'develop' into feat/gen_ssh_key
fiftin Sep 18, 2026
967a0cb
test: add test for secrets
fiftin Sep 19, 2026
3f0c99f
feat: update docs
fiftin Sep 19, 2026
5a75139
Add cleanup for util.Config in AccessKey tests
fiftin Sep 20, 2026
7c3d19e
Add cleanup for access key service test
fiftin Sep 20, 2026
5882a67
fix: detect generated SSH keys from create response
Copilot Sep 20, 2026
f97145d
Apply remaining changes
Copilot Sep 20, 2026
b8edb4d
refactor(ssh gen): make methods more readable
fiftin Sep 20, 2026
6c93273
fix: merge conflict
fiftin Sep 20, 2026
82d136d
Modify test to handle configuration cleanup
fiftin Sep 20, 2026
add71f5
feat(key gen): improve validation
fiftin Sep 21, 2026
14f4856
feat(ui): show/hide public key
fiftin Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/projects/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ func (c *KeyController) AddKey(w http.ResponseWriter, r *http.Request) {
helpers.WriteError(w, err)
return
}
key.Plain = newKey.Plain
Comment thread
fiftin marked this conversation as resolved.

helpers.WriteJSON(w, http.StatusCreated, key)
}
Expand Down
1 change: 1 addition & 0 deletions db/AccessKey.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type AccessKey struct {
String string `db:"-" json:"string"`
LoginPassword LoginPassword `db:"-" json:"login_password"`
SshKey SshKey `db:"-" json:"ssh"`
GenerateSSHKey bool `db:"-" json:"generate_ssh_key,omitempty"`
Comment thread
fiftin marked this conversation as resolved.
OverrideSecret bool `db:"-" json:"override_secret,omitempty"`

StorageID *int `db:"storage_id" json:"-" backup:"-"`
Expand Down
1 change: 1 addition & 0 deletions db/bolt/access_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func (d *BoltDb) UpdateAccessKey(key db.AccessKey) error {
return err2
}
oldKey.Name = key.Name
//oldKey.Plain = key.Plain
key = oldKey
}

Expand Down
2 changes: 1 addition & 1 deletion db/sql/access_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ func (d *SqlDb) UpdateAccessKey(key db.AccessKey) error {
}

if key.OverrideSecret {

query += ", type=?, secret=?, source_storage_id=?, source_storage_key=?, source_storage_type=?"
args = append(args, key.Type)
args = append(args, key.Secret)
args = append(args, key.Plain)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
args = append(args, key.SourceStorageID)
args = append(args, key.SourceStorageKey)
args = append(args, key.SourceStorageType)
Expand Down
50 changes: 50 additions & 0 deletions services/server/access_key_svc.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package server

import (
"bufio"
"bytes"
"encoding/json"
"errors"

"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/util"
"github.com/semaphoreui/semaphore/pkg/common_errors"
)

Expand Down Expand Up @@ -69,7 +73,48 @@ func (s *AccessKeyServiceImpl) GetAll(projectID int, options db.GetAccessKeyOpti
return s.accessKeyRepo.GetAccessKeys(projectID, options, params)
}

func maybeGenerateSSHPrivateKey(key *db.AccessKey) error {
if !key.GenerateSSHKey || key.Type != db.AccessKeySSH {
key.Plain = nil
return nil
}
Comment thread
fiftin marked this conversation as resolved.
Outdated

var b bytes.Buffer
privateKeyFile := bufio.NewWriter(&b)

publicKey, err := util.GeneratePrivateKey(privateKeyFile)
Comment thread
fiftin marked this conversation as resolved.
Outdated
if err != nil {
return err
}

err = privateKeyFile.Flush()
if err != nil {
return err
}

key.SshKey.PrivateKey = b.String()
Comment thread
Copilot marked this conversation as resolved.
Outdated

type sshPublicKey struct {
PublicKey string `json:"public_key"`
}

plainBytes, err := json.Marshal(sshPublicKey{
PublicKey: publicKey,
})
if err != nil {
return err
}

plain := string(plainBytes)
key.Plain = &plain
Comment thread
fiftin marked this conversation as resolved.
Outdated
return nil
}
Comment thread
fiftin marked this conversation as resolved.
Outdated

func (s *AccessKeyServiceImpl) Create(key db.AccessKey) (newKey db.AccessKey, err error) {
err = maybeGenerateSSHPrivateKey(&key)
if err != nil {
return
}

// SerializeSecret encrypts/persists the secret for writable backends. For read-only
// external storage the secret is not stored in Semaphore, so SerializeSecret fails
Expand All @@ -89,6 +134,11 @@ func (s *AccessKeyServiceImpl) Update(key db.AccessKey) (err error) {
return
}

err = maybeGenerateSSHPrivateKey(&key)
if err != nil {
return
}

var oldKey db.AccessKey
oldKey, err = s.accessKeyRepo.GetAccessKey(*key.ProjectID, key.ID)
if err != nil {
Expand Down
55 changes: 54 additions & 1 deletion web/src/components/KeyForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,26 +145,63 @@
dense
/>

<v-checkbox
v-model="item.generate_ssh_key"
label="Generate SSH Key"
Comment thread
fiftin marked this conversation as resolved.
v-if="!isReadOnly && item.type === 'ssh'"
:disabled="formSaving || !canEditSecrets"
Comment thread
fiftin marked this conversation as resolved.
Comment on lines +153 to +157
/>

<v-textarea
outlined
v-model="item.ssh.private_key"
:label="$t('privateKey')"
:disabled="formSaving || !canEditSecrets"
:rules="[(v) => !canEditSecrets || !!v || $t('private_key_required')]"
:rules="[(v) => !canEditSecrets || item.generate_ssh_key || !!v || $t('private_key_required')]"

Check failure on line 160 in web/src/components/KeyForm.vue

View workflow job for this annotation

GitHub Actions / build-local

This line has a length of 101. Maximum allowed is 100
v-if="!isReadOnly && item.type === 'ssh'"
/>

<div
v-if="item.type === 'ssh' && !isNew && hasGeneratedPublicKey"
class="mb-4"
>
<div style="position: relative">
<pre
style="
overflow: auto;
background: gray;
color: white;
border-radius: 10px;
margin-top: 5px;
"
class="pa-2"
>{{ publicKey }}</pre
>

<CopyClipboardButton
style="position: absolute; right: 0; top: 0; transform: scale(0.9);"
Comment thread
fiftin marked this conversation as resolved.
:text="publicKey"
/>
</div>
</div>

<v-checkbox v-model="item.override_secret" :label="$t('override')" v-if="!isNew" />


Check failure on line 190 in web/src/components/KeyForm.vue

View workflow job for this annotation

GitHub Actions / build-local

More than 1 blank line not allowed
<v-alert dense text type="info" v-if="item.type === 'none'">
{{ $t('useThisTypeOfKeyForHttpsRepositoriesAndForPlaybook') }}
</v-alert>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
import CopyClipboardButton from '@/components/CopyClipboardButton.vue';

export default {
components: {
CopyClipboardButton,
},

mixins: [ItemFormBase],

props: {
Expand Down Expand Up @@ -195,6 +232,21 @@
},

computed: {
hasGeneratedPublicKey() {
return this.publicKey !== '';
},

publicKey: {
get() {
try {
const plain = JSON.parse(this.item?.plain || '{}');
return plain.public_key || '';
} catch (e) {
return '';
}
}

Check failure on line 247 in web/src/components/KeyForm.vue

View workflow job for this annotation

GitHub Actions / build-local

Missing trailing comma
},

Check failure on line 249 in web/src/components/KeyForm.vue

View workflow job for this annotation

GitHub Actions / build-local

Trailing spaces not allowed
sourceStorageType() {
return this.item?.source_storage_type;
},
Expand Down Expand Up @@ -256,6 +308,7 @@
return {
ssh: {},
login_password: {},
generate_ssh_key: false,
};
},

Expand Down
84 changes: 82 additions & 2 deletions web/src/views/project/Keys.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
:save-button-text="itemId === 'new' ? $t('create') : $t('save')"
:title="`${itemId === 'new' ? $t('nnew') : $t('edit')} Key`"
:max-width="450"
@save="loadItems()"
@save="loadItemsAndShowPublicKey($event)"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<KeyForm
Expand All @@ -20,6 +20,37 @@
</template>
</EditDialog>

<EditDialog
:max-width="700"
v-model="createdPublicKeyDialog"
:save-button-text="null"
title="Generated SSH Public Key"
Comment thread
fiftin marked this conversation as resolved.
hide-buttons
>
<template v-slot:form="{}">
<div class="mb-4">
<div style="position: relative">
<pre
style="
overflow: auto;
background: gray;
color: white;
border-radius: 10px;
margin-top: 5px;
"
class="pa-2"
Comment thread
fiftin marked this conversation as resolved.
>{{ createdPublicKey }}</pre
>

<CopyClipboardButton
style="position: absolute; right: 10px; top: 10px"
:text="createdPublicKey"
/>
</div>
</div>
</template>
</EditDialog>

<ObjectRefsDialog
object-title="access key"
:object-refs="itemRefs"
Expand Down Expand Up @@ -94,9 +125,14 @@ import ItemListPageBase from '@/components/ItemListPageBase';
import KeyForm from '@/components/KeyForm.vue';
import PageMixin from '@/components/PageMixin';
import KeyStoreMenu from '@/components/KeyStoreMenu.vue';
import CopyClipboardButton from '@/components/CopyClipboardButton.vue';

export default {
components: { KeyStoreMenu, KeyForm },
components: {
CopyClipboardButton,
KeyStoreMenu,
KeyForm,
},

mixins: [ItemListPageBase, PageMixin],

Expand All @@ -110,7 +146,51 @@ export default {
},
},

data() {
return {
createdPublicKeyDialog: false,
createdPublicKey: '',
};
},

methods: {
async loadItemsAndShowPublicKey(e) {
await this.loadItems();

const isGeneratedOnCreate = e && e.action === 'new';
const isGeneratedOnUpdate = e && e.action === 'edit' && e.item && e.item.generate_ssh_key;
Comment thread
fiftin marked this conversation as resolved.
Outdated
if (!isGeneratedOnCreate && !isGeneratedOnUpdate) {
this.createdPublicKey = '';
return;
}

const itemId = e && e.item ? e.item.id : null;
const reloadedItem = itemId ? this.items.find((x) => x.id === itemId) : null;
const sourceItem = reloadedItem || (e || {}).item;
const publicKey = this.extractPublicKey(sourceItem);

if (!publicKey) {
this.createdPublicKey = '';
return;
}

this.createdPublicKey = publicKey;
this.createdPublicKeyDialog = true;
},

extractPublicKey(item) {
if (!item || !item.plain) {
return '';
}

try {
const plain = JSON.parse(item.plain);
return plain.public_key || '';
} catch (e) {
return '';
}
},

getHeaders() {
return [{
text: this.$i18n.t('name'),
Expand Down
Loading