fix: protect device register with admin auth; improve admin UI (full API key, copy button, styling)

This commit is contained in:
mirivlad 2026-06-01 23:22:19 +08:00
parent e828ebd44e
commit 5db3da3618
3 changed files with 56 additions and 14 deletions

View File

@ -320,7 +320,9 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Name string `json:"name"`
Name string `json:"name"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
@ -330,6 +332,14 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "name required")
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 401, "admin username and password required")
return
}
if !s.cfg.CheckAdmin(req.Username, req.Password) {
jsonErr(w, 401, "invalid admin credentials")
return
}
b := make([]byte, 20)
rand.Read(b)
@ -337,7 +347,7 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC().Format(time.RFC3339)
result, err := s.db.Exec(
_, err := s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
apiKey[:12], req.Name, apiKey, now, now,
)
@ -345,8 +355,6 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 500, err.Error())
return
}
id, _ := result.LastInsertId()
_ = id
jsonOK(w, map[string]interface{}{
"device_id": apiKey[:12],
@ -587,7 +595,29 @@ func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync Admin</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:800px;margin:0 auto}a{color:#6366f1}h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}.stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0}.row{display:flex;gap:16px}</style>
<style>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:800px;margin:0 auto}
a{color:#6366f1}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
h2{margin-top:24px}
.stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0}
.row{display:flex;gap:16px}
table{width:100%;border-collapse:collapse;margin-top:8px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase}
.key-cell{max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer}
.btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222}
.copy-btn{padding:2px 8px;font-size:11px;margin-left:6px}
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;margin-right:8px}
input:focus{outline:none;border-color:#6366f1}
form{margin-top:8px}
.copied-msg{color:#4ade80;font-size:12px;margin-left:6px}
</style>
</head><body>
<h1>Verstak Sync Server</h1>
<div class="row">
@ -600,16 +630,23 @@ func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
fetch('/admin/api/keys').then(r=>r.json()).then(keys=>{
const div=document.getElementById('keys')
if(!keys.length){div.innerHTML='<p>Нет ключей</p>';return}
div.innerHTML='<table><tr><th>Устройство</th><th>Ключ</th><th></th></tr>'+
keys.map(k=>'<tr><td>'+k.name+'</td><td><code>'+k.api_key.slice(0,16)+'</code></td>'+
'<td><button onclick="delKey(\''+k.id+'\')">Удалить</button></td></tr>').join('')+'</table>'
div.innerHTML='<table><tr><th>Устройство</th><th>API-ключ</th><th></th><th></th></tr>'+
keys.map(k=>'<tr><td>'+k.name+'</td><td class="key-cell" title="'+k.api_key+'">'+k.api_key+'</td>'+
'<td><button class="btn copy-btn" onclick="copyKey(\''+k.api_key+'\',this)">Копировать</button></td>'+
'<td><button class="btn btn-danger" onclick="delKey(\''+k.id+'\')">Удалить</button></td></tr>').join('')+'</table>'
})
function copyKey(key,btn){
navigator.clipboard.writeText(key).then(()=>{
var old=btn.textContent;btn.textContent='Скопировано';btn.style.color='#4ade80'
setTimeout(function(){btn.textContent=old;btn.style.color=''},1500)
})
}
function delKey(id){if(confirm('Удалить ключ?'))fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())}
</script>
<h2>Новый ключ</h2>
<form action="/admin/api/keys" method="POST">
<input name="name" placeholder="Название устройства" required>
<button>Создать</button>
<button class="btn btn-primary">Создать</button>
</form>
<p><a href="/api/v1/health">Health check</a></p>
</body></html>`, deviceCount, opsCount)

View File

@ -82,15 +82,15 @@ API-ключ — это токен, который клиент (Верстак
2. В разделе "API Keys" ввести имя устройства и нажать "Create".
3. Скопировать сгенерированный ключ.
Через API (без авторизации):
Через API (требует логин и пароль администратора):
```bash
curl -X POST http://localhost:47732/api/v1/device/register \
-H "Content-Type: application/json" \
-d '{"name":"мой-ноутбук"}'
-d '{"name":"мой-ноутбук","username":"admin","password":"пароль-админа"}'
```
Ответ: `{"device_id":"...","api_key":"..."}`
**Важно:** endpoint регистрации открыт без аутентификации. Не выставляйте сервер в интернет без дополнительной защиты (фаервол, VPN, reverse proxy с базовой аутентификацией).
**Важно:** не выставляйте сервер в интернет без HTTPS (через reverse proxy). До создания полноценной системы пользователей регистрация устройств требует учётных данных администратора.
### Как использовать
@ -158,7 +158,6 @@ sync:
| Метод | Путь | Описание |
|---|---|---|
| GET | `/api/v1/health` | Проверка здоровья сервера |
| POST | `/api/v1/device/register` | Регистрация устройства (без аутентификации) |
### Требуют API-ключ (Authorization: Bearer)
@ -169,6 +168,12 @@ sync:
| POST | `/api/v1/blobs/` | Загрузить blob (multipart) |
| GET | `/api/v1/blobs/{sha256}` | Скачать blob |
### Требуют логин+пароль администратора
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/v1/device/register` | Регистрация устройства (body: name + username + password) |
### Требуют сессию админа (cookie)
| Метод | Путь | Описание |

View File

@ -23,7 +23,7 @@ curl -sf "http://localhost:$SERVER_PORT/api/v1/health" | grep -q '"ok"' && echo
echo ":: Register device"
REG=$(curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/device/register" \
-H "Content-Type: application/json" -d '{"name":"smoke"}')
-H "Content-Type: application/json" -d '{"name":"smoke","username":"admin","password":"pass"}')
DID=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['device_id'])")
AKEY=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['api_key'])")
echo " device=$DID"