Files
HY2XS_flamy/apps/service/peer.go
T
founder cf094f6e6f fix(v1): сделать отзыв доступа, бэкап и диагностику соответствующими своим именам
Проход по операциям, которые делают не то, что обещает их имя.

P0. Удаление bootstrap-admin-peer не было отзывом доступа. Признаком «создавать
пир или нет» служило наличие строки в таблице, а HY2XS_ADMIN_CON_PASS
продолжает жить в /etc/hy2xs/hy2xs.env — его читает systemd-юнит. Оператор
удалял пира, доступ исчезал, и ближайший restart возвращал того же пира с тем же
секретом. Молча. Признаком стала отметка BOOTSTRAP_PEER_SEEDED в таблице config:
«создавался когда-либо», а не «существует сейчас». Отметка и пир пишутся одной
транзакцией.

P1. Резервная копия с includeSecrets=true проглатывала и ошибку расшифровки, и
отсутствие шифртекста, отдавая пира с пустым secret и успешный ответ. Теперь
недоступный секрет любого пира отклоняет весь запрос с указанием имени.

P1. DecryptPeerSecret возвращала содержимое колонки как расшифрованный секрет,
если оно не начиналось с v1: — остаток поколения с открытыми секретами.

P1. doctor перезапускал hysteria-server и hy2xs-admin: диагностика подозрения на
проблему обрывала все живые соединения.

P1. Админка сама генерировала HYSTERIA2_TRAFFIC_STATS_SECRET, записать который в
/etc/hysteria/config.yaml она не может. Сервис объявлял себя здоровым, а machine
auth переставал совпадать.

P1. Обходы проверки зависимостей (accepted-risk/skipped) не могли произвести
артефакт: приёмка требует dependency_security_gate=true. Удалены из сборки и
документации, отсутствие проверяется приёмкой.

P2. UPDATE по отсутствующей строке config считался успехом, и cron
перепланировался при несохранённом значении. Решение по RowsAffected.

P2. Слой данных не отличал «записи нет» от «база не ответила»: sentinel-значения
ErrPeerNotFound / ErrAdminUserNotFound / ErrConfigNotFound / ErrStorage.

P2. Удалены алиасы /:id/client-url и /:id/qr.

Контракт разработки: apps/go.mod объявляет toolchain go1.26.7 (директива go —
языковой baseline, а не выбор компилятора), tools/dev/doctor.sh|.ps1 сверяют
среду с versions.env.
2026-08-30 06:48:50 +05:00

646 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"fmt"
"strings"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
)
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
peers, total, err := dao.PagePeer(peerPageDto)
if err != nil {
return nil, 0, err
}
onlineUsers, _ := Hysteria2Online()
result := make([]vo.PeerVo, 0, len(peers))
for _, p := range peers {
item := vo.PeerVo{
BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime},
Name: strVal(p.Name),
Remark: strVal(p.Remark),
AuthId: strVal(p.AuthId),
QuotaBytes: int64Val(p.QuotaBytes),
DownloadBytes: int64Val(p.DownloadBytes),
UploadBytes: int64Val(p.UploadBytes),
ExpiresAt: int64Val(p.ExpiresAt),
MaxDevices: int64Val(p.MaxDevices),
Disabled: int64Val(p.Disabled),
BannedUntil: int64Val(p.BannedUntil),
LastConnectionAt: int64Val(p.LastConnectionAt),
}
authID := strVal(p.AuthId)
if v, ok := onlineUsers[authID]; ok {
item.Online = true
item.OnlineDevices = v
}
result = append(result, item)
}
return result, total, nil
}
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
//
// Что было. ReservedBootstrapPeerName проверялся исключительно в
// applyPeerImportEntry. Обычный CRUD панели такой проверки не имел, то есть
// ровно то, ради чего guard существует, делалось через интерфейс: секрет
// bootstrap-admin-peer продублирован в /etc/hy2xs/bootstrap-admin.secret,
// откуда его читает проверка machine-auth оркестратора, и смена секрета через
// форму молча рассинхронизировала бы файл с базой. Дверей в таблицу было две,
// а замок висел на одной.
//
// Границы намеренно разные для разных операций:
//
// идентичность и секрет — запрещены: они и есть то, что дублируется в файле;
// удаление и отключение — разрешены: это осознанные действия оператора,
// результат которых виден в списке пиров. Bootstrap-пир после установки
// остаётся действующим доступом, и запрет его убрать означал бы вечный
// неотзываемый доступ.
var errBootstrapPeerIdentity = fmt.Errorf(
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
ReservedBootstrapPeerName,
)
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
if peerDto.Name == nil || *peerDto.Name == "" {
return vo.PeerVo{}, errors.New(constant.InvalidError)
}
// Имя зарезервировано за установщиком даже когда сам пир уже удалён:
// иначе после удаления обычный пир мог бы занять это имя и оказаться под
// защитой, предназначенной не ему.
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
}
taken, err := ExistPeerName(*peerDto.Name, 0)
if err != nil {
return vo.PeerVo{}, err
}
if taken {
return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name)
}
secret := ""
if peerDto.Secret != nil && *peerDto.Secret != "" {
secret = *peerDto.Secret
} else {
generated, err := util.RandomString(24)
if err != nil {
return vo.PeerVo{}, err
}
secret = fmt.Sprintf("%s.%s", *peerDto.Name, generated)
}
authId, err := util.RandomString(18)
if err != nil {
return vo.PeerVo{}, err
}
secretDigest, err := PeerSecretDigest(secret)
if err != nil {
return vo.PeerVo{}, err
}
secretEncrypted, err := EncryptPeerSecret(secret)
if err != nil {
return vo.PeerVo{}, err
}
peer := entity.Peer{
Name: peerDto.Name,
Remark: peerDto.Remark,
AuthId: &authId,
SecretDigest: &secretDigest,
SecretEncrypted: &secretEncrypted,
QuotaBytes: peerDto.QuotaBytes,
ExpiresAt: peerDto.ExpiresAt,
MaxDevices: peerDto.MaxDevices,
Disabled: peerDto.Disabled,
}
id, saveErr := dao.SavePeer(peer)
if saveErr != nil {
return vo.PeerVo{}, saveErr
}
return GetPeerVo(id)
}
func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil {
return err
}
updates := map[string]interface{}{}
if peerDto.Name != nil && *peerDto.Name != "" {
updates["name"] = *peerDto.Name
}
if peerDto.Secret != nil && *peerDto.Secret != "" {
digest, err := PeerSecretDigest(*peerDto.Secret)
if err != nil {
return err
}
enc, err := EncryptPeerSecret(*peerDto.Secret)
if err != nil {
return err
}
updates["secret_digest"] = digest
updates["secret_ciphertext"] = enc
}
if peerDto.QuotaBytes != nil {
updates["quota_bytes"] = *peerDto.QuotaBytes
}
if peerDto.ExpiresAt != nil {
updates["expires_at"] = *peerDto.ExpiresAt
}
if peerDto.MaxDevices != nil {
updates["max_devices"] = *peerDto.MaxDevices
}
if peerDto.Disabled != nil {
updates["disabled"] = *peerDto.Disabled
}
if peerDto.Remark != nil {
updates["remark"] = *peerDto.Remark
}
return dao.UpdatePeer([]int64{id}, updates)
}
// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано
// в /etc/hy2xs/bootstrap-admin.secret, и занимать зарезервированное имя.
func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) error {
// Переименование ЛЮБОГО пира в зарезервированное имя запрещено отдельно от
// проверки цели: UNIQUE(name) закрывает этот путь только пока bootstrap-пир
// существует.
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
existing, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
}
}
existing, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return nil
}
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) != "" &&
strings.TrimSpace(*peerDto.Name) != ReservedBootstrapPeerName {
return errBootstrapPeerIdentity
}
if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" {
return errBootstrapPeerIdentity
}
return nil
}
// DeletePeer удаляет пира, включая bootstrap-пира установщика.
//
// Удаление намеренно НЕ запрещено. Bootstrap-пир после установки — обычный
// действующий доступ, секрет которого лежит ещё и в файле на диске; оператор
// обязан иметь возможность его отозвать. В отличие от смены секрета, удаление
// не создаёт расхождения между базой и файлом: пира просто нет, и это видно в
// списке.
//
// Отзыв при этом ОКОНЧАТЕЛЬНЫЙ, и это свойство обеспечивается не здесь, а
// отметкой constant.BootstrapPeerSeeded в dao.ensureSecureBootstrapPeer. Пока
// признаком «создавать или нет» служило наличие строки в таблице, удаление
// откатывалось первым же перезапуском сервиса: HY2XS_ADMIN_CON_PASS никуда не
// девается из /etc/hy2xs/hy2xs.env, и seeder создавал пира с тем же секретом
// заново. Разрешать удаление имеет смысл только вместе с этим свойством, иначе
// панель предлагает операции, которых не выполняет.
//
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже
// недействующее значение (см. docs/04-admin-panel.md).
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
func GetPeerVo(id int64) (vo.PeerVo, error) {
p, err := dao.GetPeer("id = ?", id)
if err != nil {
return vo.PeerVo{}, err
}
return vo.PeerVo{
BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime},
Name: strVal(p.Name),
Remark: strVal(p.Remark),
AuthId: strVal(p.AuthId),
QuotaBytes: int64Val(p.QuotaBytes),
DownloadBytes: int64Val(p.DownloadBytes),
UploadBytes: int64Val(p.UploadBytes),
ExpiresAt: int64Val(p.ExpiresAt),
MaxDevices: int64Val(p.MaxDevices),
Disabled: int64Val(p.Disabled),
BannedUntil: int64Val(p.BannedUntil),
LastConnectionAt: int64Val(p.LastConnectionAt),
}, nil
}
func ResetPeerTraffic(id int64) error {
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0})
}
func ReleaseKickPeer(id int64) error {
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0})
}
func KickPeer(id int64, bannedUntil int64) error {
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil {
return err
}
return Hysteria2Kick([]int64{id}, bannedUntil)
}
func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
url, err := Hysteria2Url(id)
if err != nil {
return vo.PeerClientConfigVo{}, err
}
return vo.PeerClientConfigVo{Url: url}, nil
}
// ListExportPeer собирает выгрузку пиров в одном из двух режимов.
//
// includeSecrets=false — безопасная выгрузка: шифртекст не трогается вовсе,
// поле secret остаётся пустым, и это ожидаемое содержимое файла.
//
// includeSecrets=true — резервная копия с ДЕЙСТВУЮЩИМИ учётными данными,
// которую оператор запрашивает отдельной кнопкой с подтверждением риска.
// Здесь правило строгое: либо в файл попадают все секреты, либо файла нет.
//
// Что было:
//
// if includeSecrets && item.SecretEncrypted != nil {
// if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
// ex.Secret = dec
// }
// }
//
// Два молчаливых канала потери. Ошибка расшифровки проглатывалась, и пир
// уезжал в файл с пустым secret; пир без шифртекста — тоже. Запрос при этом
// завершался успехом, и оператор получал файл, выглядящий полным:
//
// [{"name":"A","secret":"..."},
// {"name":"B","secret":""}, <- потерян молча
// {"name":"C","secret":"..."}]
//
// Обнаруживалось это уже после импорта на новом сервере: B получал новый
// сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима в
// том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»;
// частичный результат под этим именем — худший из возможных ответов.
//
// Сообщение называет пира по имени: имена не секретны и уже видны в списке, а
// без имени оператору некуда идти с этой ошибкой.
func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
peers, err := dao.ListPeer("1=1")
if err != nil {
return nil, err
}
out := make([]bo.PeerExport, 0, len(peers))
for _, item := range peers {
ex := bo.PeerExport{
Id: int64Val(item.Id),
AuthId: strVal(item.AuthId),
Name: strVal(item.Name),
Remark: strVal(item.Remark),
QuotaBytes: int64Val(item.QuotaBytes),
DownloadBytes: int64Val(item.DownloadBytes),
UploadBytes: int64Val(item.UploadBytes),
ExpiresAt: int64Val(item.ExpiresAt),
MaxDevices: int64Val(item.MaxDevices),
Disabled: int64Val(item.Disabled),
BannedUntil: int64Val(item.BannedUntil),
LastConnectionAt: int64Val(item.LastConnectionAt),
}
if includeSecrets {
secret, secretErr := exportPeerSecret(item)
if secretErr != nil {
return nil, secretErr
}
ex.Secret = secret
}
out = append(out, ex)
}
return out, nil
}
// exportPeerSecret возвращает действующий секрет пира или объясняет, почему
// резервная копия невозможна.
func exportPeerSecret(peer entity.Peer) (string, error) {
name := strVal(peer.Name)
if name == "" {
name = fmt.Sprintf("id=%d", int64Val(peer.Id))
}
if peer.SecretEncrypted == nil || strings.TrimSpace(*peer.SecretEncrypted) == "" {
return "", fmt.Errorf(
"резервная копия с учётными данными не создана: у пира %q нет сохранённого секрета. "+
"Файл не сформирован целиком, чтобы копия не выглядела полной. "+
"Пересоздайте секрет этого пира или выгрузите список без учётных данных",
name,
)
}
decrypted, err := DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
return "", fmt.Errorf(
"резервная копия с учётными данными не создана: не удалось расшифровать секрет пира %q. "+
"Обычная причина — подменённый или потерянный PEER_SECRET_ENCRYPTION_KEY. "+
"Файл не сформирован целиком, чтобы копия не выглядела полной",
name,
)
}
return decrypted, nil
}
// preparedPeerImport — запись импорта со всем криптоматериалом, посчитанным
// заранее.
//
// Крипто выносится ИЗ транзакции сознательно. PeerSecretDigest и
// EncryptPeerSecret читают ключи из таблицы `config`, то есть ходят в ту же
// базу; делать это, удерживая открытую запись, значит без нужды держать
// блокировку на время AES по каждой из тысяч записей. Внутри транзакции должна
// остаться только работа с таблицей пиров.
type preparedPeerImport struct {
source bo.PeerExport
name string
authID string
remark string
quota int64
expires int64
maxDevices int64
disabled int64
// Задан, только если секрет пришёл в файле: у существующего пира секрет
// перезаписывается лишь в этом случае.
hasExplicitSecret bool
explicitDigest string
explicitCipher string
// Готовятся всегда: понадобятся, если запись окажется новой.
createDigest string
createCipher string
createAuthID string
}
func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
prepared := make([]preparedPeerImport, 0, len(items))
for _, item := range items {
name := strings.TrimSpace(item.Name)
authID := strings.TrimSpace(item.AuthId)
maxDevices := item.MaxDevices
if maxDevices <= 0 {
maxDevices = 3
}
entry := preparedPeerImport{
source: item,
name: name,
authID: authID,
remark: item.Remark,
quota: item.QuotaBytes,
expires: item.ExpiresAt,
maxDevices: maxDevices,
disabled: item.Disabled,
}
explicitSecret := strings.TrimSpace(item.Secret)
if explicitSecret != "" {
digest, err := PeerSecretDigest(explicitSecret)
if err != nil {
return nil, err
}
cipher, err := EncryptPeerSecret(explicitSecret)
if err != nil {
return nil, err
}
entry.hasExplicitSecret = true
entry.explicitDigest = digest
entry.explicitCipher = cipher
entry.createDigest = digest
entry.createCipher = cipher
} else {
generated, err := util.RandomString(24)
if err != nil {
return nil, err
}
createSecret := fmt.Sprintf("%s.%s", name, generated)
digest, err := PeerSecretDigest(createSecret)
if err != nil {
return nil, err
}
cipher, err := EncryptPeerSecret(createSecret)
if err != nil {
return nil, err
}
entry.createDigest = digest
entry.createCipher = cipher
}
entry.createAuthID = authID
if entry.createAuthID == "" {
generated, err := util.RandomString(18)
if err != nil {
return nil, err
}
entry.createAuthID = generated
}
prepared = append(prepared, entry)
}
return prepared, nil
}
// UpsertPeerExport применяет выгрузку пиров целиком или не применяет вовсе.
//
// Три прохода, и каждый отвечает за своё:
//
// 1. ValidatePeerImportBatch — содержимое файла, без обращения к базе;
// 2. preparePeerImport — весь криптоматериал, без обращения к таблице пиров;
// 3. одна транзакция — только записи.
//
// Раньше третьего прохода не существовало: записи шли по одной, каждая своим
// оператором. Комментарий обещал «либо целиком, либо никак», но UNIQUE-конфликт
// на 37-й записи оставлял 36 применённых, и откатить это оператор уже не мог.
// Конфликт не гипотетический: пусть в базе есть A(auth_id=a, name=alice) и
// B(auth_id=b, name=bob), а файл несёт (auth_id=a, name=bob). Поиск найдёт A
// по auth_id и переименует его в bob — прямо в UNIQUE(name).
func UpsertPeerExport(items []bo.PeerExport) error {
if err := ValidatePeerImportBatch(items); err != nil {
return err
}
prepared, err := preparePeerImport(items)
if err != nil {
return err
}
return dao.WithPeerTx(func(tx dao.PeerTx) error {
for _, entry := range prepared {
if err := applyPeerImportEntry(tx, entry); err != nil {
return err
}
}
return nil
})
}
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
existing, found, err := findPeerForImport(tx, entry)
if err != nil {
return err
}
// Пир установщика не переопределяется импортом ни при каком совпадении:
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
return fmt.Errorf(
"peer import: пир %q принадлежит установщику и не может быть изменён импортом",
ReservedBootstrapPeerName,
)
}
if found {
updates := map[string]interface{}{
"name": entry.name,
"remark": entry.remark,
"quota_bytes": entry.quota,
"download_bytes": entry.source.DownloadBytes,
"upload_bytes": entry.source.UploadBytes,
"expires_at": entry.expires,
"max_devices": entry.maxDevices,
"disabled": entry.disabled,
"banned_until": entry.source.BannedUntil,
"last_connection_at": entry.source.LastConnectionAt,
}
if entry.authID != "" {
updates["auth_id"] = entry.authID
}
if entry.hasExplicitSecret {
updates["secret_digest"] = entry.explicitDigest
updates["secret_ciphertext"] = entry.explicitCipher
}
return tx.UpdatePeer([]int64{*existing.Id}, updates)
}
name := entry.name
remark := entry.remark
authID := entry.createAuthID
digest := entry.createDigest
cipher := entry.createCipher
quota := entry.quota
expires := entry.expires
maxDevices := entry.maxDevices
disabled := entry.disabled
download := entry.source.DownloadBytes
upload := entry.source.UploadBytes
bannedUntil := entry.source.BannedUntil
lastConnection := entry.source.LastConnectionAt
peer := entity.Peer{
Name: &name,
Remark: &remark,
AuthId: &authID,
SecretDigest: &digest,
SecretEncrypted: &cipher,
QuotaBytes: &quota,
DownloadBytes: &download,
UploadBytes: &upload,
ExpiresAt: &expires,
MaxDevices: &maxDevices,
Disabled: &disabled,
BannedUntil: &bannedUntil,
LastConnectionAt: &lastConnection,
}
_, saveErr := tx.SavePeer(peer)
return saveErr
}
// findPeerForImport ищет запись, которую импорт должен обновить.
//
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше
// последних двух не существовало — код выглядел так:
//
// existing, err = tx.GetPeer("auth_id = ?", entry.authID)
// if err != nil || existing.Id == nil {
// existing, err = tx.GetPeer("name = ?", entry.name)
// }
//
// то есть отказ SQLite на первом запросе трактовался как «по auth_id ничего
// нет» и запускал поиск по имени; отказ на втором — как «такого пира нет
// вовсе», и запись СОЗДАВАЛАСЬ. При недоступной базе импорт не отказывал, а
// пытался наплодить дубликаты; целостность спасала транзакция, но решение
// принималось на несуществующих данных.
//
// Порядок поиска сохранён: authId — устойчивый идентификатор, имя — запасной
// ключ для файлов, выгруженных без него.
func findPeerForImport(tx dao.PeerTx, entry preparedPeerImport) (entity.Peer, bool, error) {
if entry.authID != "" {
peer, err := tx.GetPeer("auth_id = ?", entry.authID)
switch {
case err == nil:
return peer, true, nil
case !errors.Is(err, dao.ErrPeerNotFound):
return entity.Peer{}, false, err
}
}
peer, err := tx.GetPeer("name = ?", entry.name)
switch {
case err == nil:
return peer, true, nil
case errors.Is(err, dao.ErrPeerNotFound):
return entity.Peer{}, false, nil
default:
return entity.Peer{}, false, err
}
}
// ExistPeerName отвечает «имя занято» и отказывает, когда ответить не может.
//
// Прежняя реализация возвращала `err == nil`, то есть любой отказ хранилища
// означал «имя свободно». Проверка уникальности, которая при недоступной базе
// отвечает «можно», ничего не проверяет.
func ExistPeerName(name string, id int64) (bool, error) {
var err error
if id != 0 {
_, err = dao.GetPeer("name = ? and id != ?", name, id)
} else {
_, err = dao.GetPeer("name = ?", name)
}
switch {
case err == nil:
return true, nil
case errors.Is(err, dao.ErrPeerNotFound):
return false, nil
default:
return false, err
}
}
func UpdatePeerLastConnectionAt(id int64, conAt int64) error {
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"last_connection_at": conAt})
}
func strVal(v *string) string {
if v == nil {
return ""
}
return *v
}
func int64Val(v *int64) int64 {
if v == nil {
return 0
}
return *v
}