diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index df0973a..6b241ce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - repository: [log-alert, chrooted-sshd, immich-souvenirs, dnsupdater, rsync-server, sshd, webhook, gandi, http-tunnel, restic-auto, restic-rest, shairport-sync, telegraf] + repository: [log-alert, immich-souvenirs, dnsupdater, webhook, restic-auto, shairport-sync] steps: - name: Checkout diff --git a/chrooted-sshd/Dockerfile b/chrooted-sshd/Dockerfile index ebe86a5..96c0cd9 100644 --- a/chrooted-sshd/Dockerfile +++ b/chrooted-sshd/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:latest +FROM alpine:3.21 RUN apk add --no-cache openssh diff --git a/chrooted-sshd/entrypoint.sh b/chrooted-sshd/entrypoint.sh old mode 100644 new mode 100755 diff --git a/dnsupdater/Dockerfile b/dnsupdater/Dockerfile index 72f755d..5935a54 100644 --- a/dnsupdater/Dockerfile +++ b/dnsupdater/Dockerfile @@ -1,8 +1,20 @@ +FROM alpine:latest AS builder + +ARG TARGETPLATFORM + +RUN DOWNLOAD_ARCH=$(echo ${TARGETPLATFORM} | cut -d"/" -f 2) \ + && apk add --no-cache curl \ + && DOWNLOAD_URL=$(curl -s https://api.github.com/repos/bdd/runitor/releases/latest | grep "browser_download_url" | grep "linux-"${DOWNLOAD_ARCH} | cut -d\" -f4) \ + && curl --retry 3 -L -s -o runitor ${DOWNLOAD_URL} \ + && chmod +x runitor + FROM alpine:latest -ADD dnsupdater.sh /usr/bin/dnsupdater.sh +COPY --from=builder runitor /usr/bin/ + +ADD dnsupdater.sh docker-command.sh /usr/bin/ RUN apk add --no-cache bash curl jq bind-tools \ && chmod +x /usr/bin/dnsupdater.sh -CMD /usr/bin/dnsupdater.sh +CMD ["/usr/bin/docker-command.sh"] diff --git a/dnsupdater/dnsupdater.sh b/dnsupdater/dnsupdater.sh index c4983fa..e27dec5 100755 --- a/dnsupdater/dnsupdater.sh +++ b/dnsupdater/dnsupdater.sh @@ -1,59 +1,58 @@ #!/bin/bash -while true ; do - # Get my current IP - my_ip=$(curl -s https://api.ipify.org) +# Get my IP +my_ip=$(curl -s https://api.ipify.org) +if [[ ! ("$my_ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$) ]] ; then + echo "Got incorrect IP: $my_ip" + exit 1 +fi - # Get my currently registered IP - current_ip= - if [[ "$GANDI_API_KEY" != "" ]] ; then - current_ip=$(curl -s -H"X-Api-Key: $GANDI_API_KEY" https://dns.api.gandi.net/api/v5/domains/$DNS_DOMAIN/records | jq -r '.[] | select(.rrset_name == "'$DNS_HOST'") | select(.rrset_type == "A") | .rrset_values[0]') - elif [[ "$OVH_USERNAME" != "" ]] ; then - master_server=$(dig +short -t NS $DNS_DOMAIN | head -n 1) - current_ip=$(dig +short @$master_server $DNS_HOST.$DNS_DOMAIN) +# Get my registered IP +current_ip= +if [[ "$GANDI_API_KEY" != "" ]] ; then + current_ip=$(curl -s -H"X-Api-Key: $GANDI_API_KEY" https://dns.api.gandi.net/api/v5/domains/$DNS_DOMAIN/records | jq -r '.[] | select(.rrset_name == "'$DNS_HOST'") | select(.rrset_type == "A") | .rrset_values[0]') +elif [[ "$OVH_USERNAME" != "" ]] ; then + master_server=$(dig +short -t NS $DNS_DOMAIN | head -n 1) + current_ip=$(dig +short @$master_server $DNS_HOST.$DNS_DOMAIN) +else + echo "No DNS provider configured" + exit 2 +fi +if [[ ! ("$current_ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$) ]] ; then + echo "Incorrect registered IP: $current_ip" + exit 3 +fi + +# If they match, do nothing +if [[ "$my_ip" == "$current_ip" ]]; then + echo "$DNS_HOST.$DNS_DOMAIN record already up-to-date with IP $my_ip" + exit 0 +fi + +# Update the DNS record +echo "Updating $DNS_HOST.$DNS_DOMAIN record with IP $my_ip" +if [[ "$GANDI_API_KEY" != "" ]] ; then + current_record=$(curl -s -H"X-Api-Key: $GANDI_API_KEY" https://dns.api.gandi.net/api/v5/domains/$DNS_DOMAIN/records | jq -c '.[] | select(.rrset_name == "'$DNS_HOST'") | select(.rrset_type == "A")') + current_ttl=$(echo $current_record | jq -r '.rrset_ttl') + curl -s -X PUT -H "Content-Type: application/json" -H "X-Api-Key: $GANDI_API_KEY" -d '{"rrset_ttl": '$current_ttl', "rrset_values":["'$my_ip'"]}' https://dns.api.gandi.net/api/v5/domains/$DNS_DOMAIN/records/$DNS_HOST/A + if [[ $? != 0 ]] ; then + echo "Unable to update GANDI record" + exit 4 fi - - # Check if both IP addresses are correct - if [[ "$my_ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ && "$current_ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]] ; then - # If they do not match, change it (and keep the TTL and TYPE) - if [[ "$my_ip" != "$current_ip" ]]; then - result=1 - echo "Updating $DNS_HOST.$DNS_DOMAIN record with IP $my_ip" - if [[ "$GANDI_API_KEY" != "" ]] ; then - current_record=$(curl -s -H"X-Api-Key: $GANDI_API_KEY" https://dns.api.gandi.net/api/v5/domains/$DNS_DOMAIN/records | jq -c '.[] | select(.rrset_name == "'$DNS_HOST'") | select(.rrset_type == "A")') - current_ttl=$(echo $current_record | jq -r '.rrset_ttl') - curl -s -X PUT -H "Content-Type: application/json" -H "X-Api-Key: $GANDI_API_KEY" -d '{"rrset_ttl": '$current_ttl', "rrset_values":["'$my_ip'"]}' https://dns.api.gandi.net/api/v5/domains/$DNS_DOMAIN/records/$DNS_HOST/A - result=$? - elif [[ "$OVH_USERNAME" != "" ]] ; then - curl -s --user "$OVH_USERNAME:$OVH_PASSWORD" "http://www.ovh.com/nic/update?system=dyndns&hostname=$DNS_HOST.$DNS_DOMAIN&myip=$my_ip" - result=$? - fi - - # If the update was OK - if [[ $result == 0 ]] ; then - # Send a notification to Slack - if [[ "$SLACK_URL" != "" ]] ; then - curl -o /dev/null -s -m 10 --retry 5 -X POST -d "payload={\"username\": \"gandi\", \"icon_emoji\": \":dart:\", \"text\": \"New IP $my_ip for host $DNS_HOST.$DNS_DOMAIN\"}" $SLACK_URL - fi - - # Send a notification to Gotify - if [[ "$GOTIFY_URL" != "" ]] ; then - curl -o /dev/null -s -m 10 --retry 5 -X POST -H "accept: application/json" -H "Content-Type: application/json" -d "{\"priority\": 5, \"title\": \"New IP for host $DNS_HOST.$DNS_DOMAIN\", \"message\": \"New IP $my_ip for host $DNS_HOST.$DNS_DOMAIN, the old IP was $current_ip\"}" $GOTIFY_URL - fi - - # Send a notification to Healthchecks - if [[ "$HEALTHCHECKS_URL" != "" ]] ; then - curl -o /dev/null -s -m 10 --retry 5 $HEALTHCHECKS_URL - fi - fi - else - # Send a notification to Healthchecks - if [[ "$HEALTHCHECKS_URL" != "" ]] ; then - curl -o /dev/null -s -m 10 --retry 5 $HEALTHCHECKS_URL - fi - fi +elif [[ "$OVH_USERNAME" != "" ]] ; then + curl -s --user "$OVH_USERNAME:$OVH_PASSWORD" "http://www.ovh.com/nic/update?system=dyndns&hostname=$DNS_HOST.$DNS_DOMAIN&myip=$my_ip" + if [[ $? != 0 ]] ; then + echo "Unable to update OVH record" + exit 5 fi +fi - # Wait 5 minutes - sleep 300 -done +# Send a notification to Slack +if [[ "$SLACK_URL" != "" ]] ; then + curl -o /dev/null -s -m 10 --retry 5 -X POST -d "payload={\"username\": \"gandi\", \"icon_emoji\": \":dart:\", \"text\": \"New IP $my_ip for host $DNS_HOST.$DNS_DOMAIN\"}" $SLACK_URL +fi + +# Send a notification to Gotify +if [[ "$GOTIFY_URL" != "" ]] ; then + curl -o /dev/null -s -m 10 --retry 5 -X POST -H "accept: application/json" -H "Content-Type: application/json" -d "{\"priority\": 5, \"title\": \"New IP for host $DNS_HOST.$DNS_DOMAIN\", \"message\": \"New IP $my_ip for host $DNS_HOST.$DNS_DOMAIN, the old IP was $current_ip\"}" $GOTIFY_URL +fi diff --git a/dnsupdater/docker-command.sh b/dnsupdater/docker-command.sh new file mode 100755 index 0000000..4d0918a --- /dev/null +++ b/dnsupdater/docker-command.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +/usr/bin/runitor -every 5m -slug ${HOSTNAME}-dnsupdater -- /usr/bin/dnsupdater.sh diff --git a/immich-souvenirs/Dockerfile b/immich-souvenirs/Dockerfile index 6596a45..fa5d7e6 100644 --- a/immich-souvenirs/Dockerfile +++ b/immich-souvenirs/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine AS builder +FROM golang:1.23-alpine AS builder WORKDIR $GOPATH/src/napnap75/immich-souvenirs/ COPY immich-souvenirs.go . diff --git a/immich-souvenirs/README.md b/immich-souvenirs/README.md deleted file mode 100644 index b8df464..0000000 --- a/immich-souvenirs/README.md +++ /dev/null @@ -1,7 +0,0 @@ -## Development env -docker run --rm -it -v $PWD:/go/src/napnap75/immich-souvenirs -v immich-souvenirs_config:/config golang:1.21-alpine /bin/sh -apk add --no-cache git gcc musl-dev nano -cd src/napnap75/immich-souvenirs/ -go mod init github.com/napnap75/multiarch-docker-files/immich-souvenirs -go get -d -v -env IMMICH-URL="https://immich.nappez.com" env IMMICH-KEY="wRJWYUGlEfWGQ3a5lckHivLCU40s7ldEZSmikpmsE30" env WHATSAPP-SESSION-FILE="/config/ws.gob" env WHATSAPP-GROUP="120363288639885954@g.us" env RUN-ONCE=true go run immich-souvenirs.go diff --git a/immich-souvenirs/immich-souvenirs.go b/immich-souvenirs/immich-souvenirs.go index ab45794..a7743f2 100644 --- a/immich-souvenirs/immich-souvenirs.go +++ b/immich-souvenirs/immich-souvenirs.go @@ -1,11 +1,7 @@ package main import ( - "bytes" - "context" - "encoding/json" "fmt" - "io/ioutil" "math" "net/http" "os" @@ -13,472 +9,229 @@ import ( "strconv" "strings" "time" - "github.com/mdp/qrterminal/v3" - _ "github.com/mattn/go-sqlite3" - "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/store/sqlstore" - waProto "go.mau.fi/whatsmeow/binary/proto" - waLog "go.mau.fi/whatsmeow/util/log" + + "github.com/napnap75/multiarch-docker-files/immich-souvenirs/internals/immich" + "github.com/napnap75/multiarch-docker-files/immich-souvenirs/internals/whatsapp" ) type Parameters struct { - ImmichURL string - ImmichKey string - WhatsappSessionFile string - WhatsappGroup string - TimeToRun string - RunOnce bool - HealthchecksURL string -} - -type Album struct { - ID string `json:"id"` - Name string `json:"albumName"` - Shared bool `json:"shared"` - HasSharedLink bool `json:"hasSharedLink"` - StartDate time.Time `json:"startDate"` - CreatedAt time.Time `json:"createdAt"` - AlbumThumbnailAssetId string `json:"albumThumbnailAssetId"` -} - -type Key struct { - ID string `json:"id"` - Key string `json:"key"` - Album *Album `json:"album"` -} - -func connect(param Parameters) (*whatsmeow.Client, error) { - dbLog := waLog.Stdout("Database", "ERROR", true) - container, err := sqlstore.New("sqlite3", "file:" + param.WhatsappSessionFile + "?_foreign_keys=on", dbLog) - if err != nil { - return nil, err - } - deviceStore, err := container.GetFirstDevice() - if err != nil { - return nil, err - } - clientLog := waLog.Stdout("Client", "ERROR", true) - client := whatsmeow.NewClient(deviceStore, clientLog) - - if client.Store.ID == nil { - // No ID stored, new login - qrChan, _ := client.GetQRChannel(context.Background()) - err = client.Connect() - if err != nil { - return nil, err - } - for evt := range qrChan { - if evt.Event == "code" { - qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) - fmt.Println("QR code:", evt.Code) - } else { - fmt.Println("Login event:", evt.Event) - } - } - } else { - // Already logged in, just connect - err = client.Connect() - if err != nil { - return nil, err - } - } - - return client, nil -} - -func sendMessage(client *whatsmeow.Client, group string, message string, url string, title string, thumbnail []byte) error { - jid, err := types.ParseJID(group) - if err != nil { - return fmt.Errorf("Incorrect group identifier '%s': %v", group, err) - } - - msg := &waProto.Message{ExtendedTextMessage: &waProto.ExtendedTextMessage{ - Text: proto.String(message), - Title: proto.String(title), - Description: proto.String(title), - CanonicalURL: proto.String(url), - MatchedText: proto.String(url), - JPEGThumbnail: thumbnail, - }} - ts, err := client.SendMessage(context.Background(), jid, msg) - if err != nil { - return fmt.Errorf("Error sending message with title '%s': %v", title, err) - } - - fmt.Fprintf(os.Stdout, "Message with title '%s' sent (timestamp: %s)\n", title, ts) - return nil -} - -func testConnexions(param Parameters) error { - // Create new WhatsApp connection and connect - client, err := connect(param) - if err != nil { - return fmt.Errorf("Error connecting to WhatsApp: %v", err) - } - <-time.After(3 * time.Second) - defer client.Disconnect() - - // Prints the available groups if none provided - if param.WhatsappGroup == "" { - fmt.Fprintf(os.Stdout, "No WhatsApp group provided, showing all available groups\n") - groups, err := client.GetJoinedGroups() - if err != nil { - return fmt.Errorf("Error getting groups list: %v", err) - } - for _, groupInfo := range groups { - fmt.Fprintf(os.Stdout, "%s | %s\n", groupInfo.JID, groupInfo.GroupName) - } - - return fmt.Errorf("No WhatsApp group provided") - } else { - jid, err := types.ParseJID(param.WhatsappGroup) - if err != nil { - return fmt.Errorf("Incorrect group identifier '%s': %v", param.WhatsappGroup, err) - } - _, err = client.GetGroupInfo(jid) - if err != nil { - return fmt.Errorf("Unknown WhatsApp group %s", param.WhatsappGroup) - } - } - - // Connects to Immich and load albums - spaceClient := http.Client{ - Timeout: time.Second * 10, - } - req, err := http.NewRequest(http.MethodGet, param.ImmichURL + "/api/albums", nil) - if err != nil { - return fmt.Errorf("Error connecting to Immich with URL '%s': %v", param.ImmichURL, err) - } - req.Header.Set("x-api-key", param.ImmichKey) - req.Header.Set("Accept", "application/json") - - res, err := spaceClient.Do(req) - if err != nil { - return fmt.Errorf("Error connecting to Immich with URL '%s': %v", param.ImmichURL, err) - } - if res.Body != nil { - defer res.Body.Close() - } - if res.StatusCode != 200 { - return fmt.Errorf("Error connecting to Immich with URL '%s': Status code %d", param.ImmichURL, res.StatusCode) - } - body, err := ioutil.ReadAll(res.Body) - if err != nil { - return fmt.Errorf("Error connecting to Immich with URL '%s': %v", param.ImmichURL, err) - } - var albums []Album - err = json.Unmarshal([]byte(body), &albums) - if err != nil { - return fmt.Errorf("Error connecting to Immich with URL '%s': %v", param.ImmichURL, err) - } - - return nil -} - -func getSharingKey(album Album, param Parameters) (string, error) { - spaceClient := http.Client{ - Timeout: time.Second * 10, - } - if (album.HasSharedLink) { - // Retrieve the existing key - req, err := http.NewRequest(http.MethodGet, param.ImmichURL + "/api/shared-links", nil) - if err != nil { - return "", fmt.Errorf("Error retrieving sharing key for album '%s': %v", album.Name, err) - } - req.Header.Set("x-api-key", param.ImmichKey) - req.Header.Set("Accept", "application/json") - res, err := spaceClient.Do(req) - if err != nil { - return "", fmt.Errorf("Error retrieving sharing key for album '%s': %v", album.Name, err) - } - if res.Body != nil { - defer res.Body.Close() - } - if res.StatusCode != 200 { - return "", fmt.Errorf("Error retrieving sharing key for album '%s': Status code %d", album.Name, res.StatusCode) - } - body, err := ioutil.ReadAll(res.Body) - if err != nil { - return "", fmt.Errorf("Error retrieving sharing key for album '%s': %v", album.Name, err) - } - var keys []Key - err = json.Unmarshal([]byte(body), &keys) - if err != nil { - return "", fmt.Errorf("Error retrieving sharing key for album '%s': %v", album.Name, err) - } - for _, key := range keys { - if (album.ID == key.Album.ID) { - return key.Key, nil - } - } - - return "", fmt.Errorf("Error retrieving sharing key for album '%s': no key found for this albume", album.Name) - } else { - // Create the missing key - var jsonData = []byte(`{ - "albumId": "` + album.ID + `", - "allowDownload": true, - "allowUpload": false, - "showMetadata": true, - "type": "ALBUM" - }`) - req, err := http.NewRequest(http.MethodPost, param.ImmichURL + "/api/shared-links", bytes.NewBuffer(jsonData)) - if err != nil { - return "", fmt.Errorf("Error creating missing key for album '%s': %v", album.Name, err) - } - req.Header.Set("x-api-key", param.ImmichKey) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/octet-stream") - res, err := spaceClient.Do(req) - if err != nil { - return "", fmt.Errorf("Error creating missing key for album '%s': %v", album.Name, err) - } - if res.Body != nil { - defer res.Body.Close() - } - if res.StatusCode != 201 { - return "", fmt.Errorf("Error creating missing key for album '%s': Status code %d", album.Name, res.StatusCode) - } - body, err := ioutil.ReadAll(res.Body) - if err != nil { - return "", fmt.Errorf("Error creating missing key for album '%s': %v", album.Name, err) - } - var key Key - err = json.Unmarshal([]byte(body), &key) - if err != nil { - return "", fmt.Errorf("Error creating missing key for album '%s': %v", album.Name, err) - } - return key.Key, nil - } -} - -func getThumbnail(album Album, param Parameters) ([]byte, error) { - spaceClient := http.Client{ - Timeout: time.Second * 10, - } - - req, err := http.NewRequest(http.MethodGet, param.ImmichURL + "/api/assets/" + album.AlbumThumbnailAssetId + "/thumbnail?size=preview", nil) - if err != nil { - return nil, fmt.Errorf("Error retrieving thumbnail for album '%s': %v", album.Name, err) - } - req.Header.Set("x-api-key", param.ImmichKey) - req.Header.Set("Accept", "application/octet-stream") - res, err := spaceClient.Do(req) - if err != nil { - return nil, fmt.Errorf("Error retrieving thumbnail for album '%s': %v", album.Name, err) - } - if res.Body != nil { - defer res.Body.Close() - } - if res.StatusCode != 200 { - return nil, fmt.Errorf("Error retrieving thumbnail for album '%s': Status code %d", album.Name, res.StatusCode) - } - thumbnail, err := ioutil.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("Error retrieving thumbnail for album '%s': %v", album.Name, err) - } - - return thumbnail, nil -} - -func runLoop(param Parameters) error { - // Create new WhatsApp connection and connect - client, err := connect(param) - if err != nil { - return fmt.Errorf("Error creating connection to WhatsApp: %v", err) - } - <-time.After(3 * time.Second) - defer client.Disconnect() - - - // Connects to Immich and load albums - spaceClient := http.Client{ - Timeout: time.Second * 10, - } - req, err := http.NewRequest(http.MethodGet, param.ImmichURL + "/api/albums", nil) - if err != nil { - return fmt.Errorf("Error connecting to Immich with URL '%s': %v", param.ImmichURL, err) - } - req.Header.Set("x-api-key", param.ImmichKey) - req.Header.Set("Accept", "application/json") - - res, err := spaceClient.Do(req) - if err != nil { - return fmt.Errorf("Error connecting to Immich with URL '%s': %v", param.ImmichURL, err) - } - if res.Body != nil { - defer res.Body.Close() - } - if res.StatusCode != 200 { - return fmt.Errorf("Error connecting to Immich with URL '%s': Status code %d", param.ImmichURL, res.StatusCode) - } - body, err := ioutil.ReadAll(res.Body) - if err != nil { - return fmt.Errorf("Error connecting to Immich with URL '%s': %v", param.ImmichURL, err) - } - var albums []Album - err = json.Unmarshal([]byte(body), &albums) - if err != nil { - return fmt.Errorf("Error connecting to Immich with URL '%s': %v", param.ImmichURL, err) - } - - for _, album := range albums { - // Get albums from x years ago - if album.Shared && (album.StartDate.Month() == time.Now().Month()) && (album.StartDate.Day() == time.Now().Day()) { - // Retrieve the sharing key - sharingKey, err := getSharingKey(album, param) - if err != nil { - return fmt.Errorf("Error retrieving the sharing key for album '%s': %v", album.Name, err) - } - - // Retrieve the thumbnail - thumbnail, err := getThumbnail(album, param) - if err != nil { - return fmt.Errorf("Error retrieving thumbnail for album '%s': %v", album.Name, err) - } - - // Send the message - link := param.ImmichURL + "/share/" + sharingKey - err = sendMessage(client, param.WhatsappGroup, fmt.Sprintf("Il y a %d an(s) : %s", time.Now().Year()-album.StartDate.Year(), link), link, album.Name, thumbnail) - if err != nil { - fmt.Fprintf(os.Stderr, "Error sending message to WhatsApp for album '%s': %v\n", album.Name, err) - continue - } - } - - // Get albums created yesterday - if album.Shared && (album.CreatedAt.Year() == time.Now().AddDate(0, 0, -1).Year()) && (album.CreatedAt.Month() == time.Now().AddDate(0, 0, -1).Month()) && (album.CreatedAt.Day() == time.Now().AddDate(0, 0, -1).Day()) { - // Retrieve the sharing key - sharingKey, err := getSharingKey(album, param) - if err != nil { - return fmt.Errorf("Error retrieving the sharing key for album '%s': %v", album.Name, err) - } - - // Retrieve the thumbnail - thumbnail, err := getThumbnail(album, param) - if err != nil { - return fmt.Errorf("Error retrieving thumbnail for album '%s': %v", album.Name, err) - } - - // Send the message - link := param.ImmichURL + "/share/" + sharingKey - err = sendMessage(client, param.WhatsappGroup, fmt.Sprintf("Nouvel album : %s", link), link, album.Name, thumbnail) - if err != nil { - fmt.Fprintf(os.Stderr, "Error sending message to WhatsApp for album '%s': %v\n", album.Name, err) - continue - } - - } - } - - return nil + ImmichURL string + ImmichKey string + WhatsappSessionFile string + WhatsappGroup string + TimeToRun string + DevelopmentMode string + HealthchecksURL string } func parseTime(timeStr string) (int, int, error) { - // Split the string into hours and minutes parts := strings.Split(timeStr, ":") if len(parts) != 2 { return 0, 0, fmt.Errorf("invalid format: %s", timeStr) } - - // Convert the parts to integers hours, err := strconv.Atoi(parts[0]) if err != nil { - return 0, 0, fmt.Errorf("error converting hours: %v", err) + return 0, 0, err } - minutes, err := strconv.Atoi(parts[1]) if err != nil { - return 0, 0, fmt.Errorf("error converting minutes: %v", err) + return 0, 0, err } - return hours, minutes, nil } func main() { - // Handle interrupts to clean properly - c := make(chan os.Signal) + // Capture interrupt signal for graceful shutdown + c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) go func() { - select { - case sig := <-c: - fmt.Printf("Got %s signal. Aborting...\n", sig) - os.Exit(1) - } + <-c + fmt.Println("Got interrupt signal. Exiting...") + os.Exit(0) }() - // Load the parameters - param := new(Parameters) - param.ImmichURL = os.Getenv("IMMICH-URL") - param.ImmichKey = os.Getenv("IMMICH-KEY") - param.WhatsappSessionFile = os.Getenv("WHATSAPP-SESSION-FILE") - param.WhatsappGroup = os.Getenv("WHATSAPP-GROUP") - param.TimeToRun = os.Getenv("TIME-TO-RUN") - param.HealthchecksURL = os.Getenv("HEALTHCHECKS-URL") - _, param.RunOnce = os.LookupEnv("RUN-ONCE") + // Load parameters from environment variables + param := &Parameters{ + ImmichURL: os.Getenv("IMMICH-URL"), + ImmichKey: os.Getenv("IMMICH-KEY"), + WhatsappSessionFile: os.Getenv("WHATSAPP-SESSION-FILE"), + WhatsappGroup: os.Getenv("WHATSAPP-GROUP"), + TimeToRun: os.Getenv("TIME-TO-RUN"), + DevelopmentMode: os.Getenv("DEVELOPMENT-MODE"), + HealthchecksURL: os.Getenv("HEALTHCHECKS-URL"), + } - if param.RunOnce { - err := runLoop(*param) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + // Initialize WhatsApp connection + wac, err := whatsapp.New(param.WhatsappSessionFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error initializing WhatsApp: %v\n", err) + return + } + + // Initialize Immich client + immichClient := immich.New(param.ImmichURL, param.ImmichKey) + + switch param.DevelopmentMode { + case "run-once", "run-last": + if err := runLoop(wac, immichClient, param); err != nil { + fmt.Fprintf(os.Stderr, "Error in runLoop: %v\n", err) } - } else { - // Test the connexion on startup - err := testConnexions(*param) - if err != nil { - fmt.Fprintf(os.Stderr, "Unable to connect: %v\n", err) + case "listen": + if err := listen(wac); err != nil { + fmt.Fprintf(os.Stderr, "Error in listen: %v\n", err) + } + default: + // Test connection on startup + if err := testConnections(wac, immichClient, param); err != nil { + fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err) return } - - // Run the loop everyday + // Main scheduled loop for { - // First, wait for the appropriate time hours, minutes, err := parseTime(param.TimeToRun) if err != nil { - fmt.Fprintf(os.Stderr, "Unable to read the time provided: %v\n", err) + fmt.Fprintf(os.Stderr, "Invalid time format: %v\n", err) return } - - t := time.Now() - n := time.Date(t.Year(), t.Month(), t.Day(), hours, minutes, 0, 0, t.Location()) - d := n.Sub(t) - if d < 0 { - n = n.Add(24 * time.Hour) - d = n.Sub(t) + now := time.Now() + target := time.Date(now.Year(), now.Month(), now.Day(), hours, minutes, 0, 0, now.Location()) + if target.Before(now) { + target = target.Add(24 * time.Hour) } - fmt.Fprintf(os.Stderr, "Sleeping for: %s\n", d) - time.Sleep(d) + sleepDuration := target.Sub(now) + fmt.Printf("Sleeping for: %v\n", sleepDuration) + time.Sleep(sleepDuration) - // Then try to run it 3 times in case of error + // Retry logic for i := 0; i < 3; i++ { - err = runLoop(*param) + err := runLoop(wac, immichClient, param) if err == nil { break } - - // Print the error and attempt number fmt.Fprintf(os.Stderr, "Attempt %d failed: %v\n", i+1, err) - // Wait before the next attempt time.Sleep(time.Duration(math.Pow(2, float64(i))) * 30 * time.Second) } - - // Manage the error if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - continue - } else { - if param.HealthchecksURL != "" { - client := http.Client{ - Timeout: time.Second * 10, - } - _, err := client.Head(param.HealthchecksURL) - if err != nil { - fmt.Fprintf(os.Stderr, "%s", err) - } + fmt.Fprintf(os.Stderr, "Error after retries: %v\n", err) + } else if param.HealthchecksURL != "" { + client := &http.Client{Timeout: 10 * time.Second} + _, err := client.Head(param.HealthchecksURL) + if err != nil { + fmt.Fprintf(os.Stderr, "Healthcheck error: %v\n", err) } } } } } + +// Fonction pour tester la connexion à WhatsApp et Immich +func testConnections(wac *whatsapp.WhatsAppClient, immichClient *immich.ImmichClient, param *Parameters) error { + fmt.Println("Testing connections...") + // Test WhatsApp + if err := wac.Client.Connect(); err != nil { + return fmt.Errorf("WhatsApp connection failed: %v", err) + } + defer wac.Client.Disconnect() + fmt.Println("WhatsApp connected.") + + // Test Immich + albums, err := immichClient.FetchAlbums() + if err != nil { + return fmt.Errorf("Immich connection failed: %v", err) + } + fmt.Printf("Fetched %d albums from Immich.\n", len(albums)) + return nil +} + +// Fonction principale pour exécuter la logique +func runLoop(wac *whatsapp.WhatsAppClient, immichClient *immich.ImmichClient, param *Parameters) error { + // Connecter WhatsApp si nécessaire + if !wac.Client.IsConnected() { + if err := wac.Client.Connect(); err != nil { + return err + } + } + defer wac.Client.Disconnect() + + // Charger albums depuis Immich + albums, err := immichClient.FetchAlbums() + if err != nil { + return err + } + + for _, album := range albums { + if album.Shared { + // Récupérer les albums anniversaire + if param.DevelopmentMode == "run-last" || (album.StartDate.Month() == time.Now().Month() && album.StartDate.Day() == time.Now().Day()) { + // Obtenir la clé de partage + sharingKey, err := immichClient.GetSharingKey(album) + if err != nil { + fmt.Fprintf(os.Stderr, "Erreur récupération clé partage: %v\n", err) + continue + } + link := param.ImmichURL + "/share/" + sharingKey + + // Récupérer la miniature + thumbnail, err := immichClient.GetThumbnail(album.AlbumThumbnailAssetId) + if err != nil { + fmt.Fprintf(os.Stderr, "Erreur miniature: %v\n", err) + continue + } + + // Envoyer le message + err = wac.SendMessage(param.WhatsappGroup, album.Name, album.Description, fmt.Sprintf("Il y a %d an(s) : %s", time.Now().Year()-album.StartDate.Year(), link), link, thumbnail) + if err != nil { + fmt.Fprintf(os.Stderr, "Erreur envoi WhatsApp: %v\n", err) + } + } + + if param.DevelopmentMode == "run-last" { + return nil + } + + // Récupérer les albums de la veille + if album.CreatedAt.Year() == time.Now().AddDate(0, 0, -1).Year() && (album.CreatedAt.Month() == time.Now().AddDate(0, 0, -1).Month()) && (album.CreatedAt.Day() == time.Now().AddDate(0, 0, -1).Day()) { + // Obtenir la clé de partage + sharingKey, err := immichClient.GetSharingKey(album) + if err != nil { + fmt.Fprintf(os.Stderr, "Erreur récupération clé partage: %v\n", err) + continue + } + link := param.ImmichURL + "/share/" + sharingKey + + // Récupérer la miniature + thumbnail, err := immichClient.GetThumbnail(album.AlbumThumbnailAssetId) + if err != nil { + fmt.Fprintf(os.Stderr, "Erreur miniature: %v\n", err) + continue + } + + // Envoyer le message + err = wac.SendMessage(param.WhatsappGroup, album.Name, album.Description, fmt.Sprintf("Nouvel album : %s", link), link, thumbnail) + if err != nil { + fmt.Fprintf(os.Stderr, "Erreur envoi WhatsApp: %v\n", err) + } + } + } + } + return nil +} + +// Fonction pour écouter les événements WhatsApp +func listen(wac *whatsapp.WhatsAppClient) error { + if err := wac.Client.Connect(); err != nil { + return err + } + defer wac.Client.Disconnect() + + wac.Client.AddEventHandler(func(evt interface{}) { + fmt.Println("Réception d'un message:", evt) + }) + + // Attendre une interruption + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + <-c + fmt.Println("Arrêt demandé.") + return nil +} diff --git a/immich-souvenirs/internals/immich/immich.go b/immich-souvenirs/internals/immich/immich.go new file mode 100644 index 0000000..8735275 --- /dev/null +++ b/immich-souvenirs/internals/immich/immich.go @@ -0,0 +1,169 @@ +package immich + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +type Album struct { + ID string `json:"id"` + Name string `json:"albumName"` + Description string `json:"description"` + Shared bool `json:"shared"` + HasSharedLink bool `json:"hasSharedLink"` + StartDate time.Time `json:"startDate"` + CreatedAt time.Time `json:"createdAt"` + AlbumThumbnailAssetId string `json:"albumThumbnailAssetId"` +} + +type Key struct { + ID string `json:"id"` + Key string `json:"key"` + Album *Album `json:"album"` +} + +type ImmichClient struct { + BaseURL string + APIKey string +} + +// Fonction pour créer une instance d'ImmichClient +func New(baseURL string, apiKey string) *ImmichClient { + return &ImmichClient{ + BaseURL: baseURL, + APIKey: apiKey, + } +} + +// Récupérer la liste des albums +func (ic *ImmichClient) FetchAlbums() ([]Album, error) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodGet, ic.BaseURL+"/api/albums", nil) + if err != nil { + return nil, err + } + req.Header.Set("x-api-key", ic.APIKey) + req.Header.Set("Accept", "application/json") + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, fmt.Errorf("Status code %d", res.StatusCode) + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var albums []Album + if err := json.Unmarshal(body, &albums); err != nil { + return nil, err + } + return albums, nil +} + +// Obtenir la clé de partage pour un album +func (ic *ImmichClient) GetSharingKey(album Album) (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + if album.HasSharedLink { + // Récupérer la clé existante + req, err := http.NewRequest(http.MethodGet, ic.BaseURL+"/api/shared-links", nil) + if err != nil { + return "", err + } + req.Header.Set("x-api-key", ic.APIKey) + req.Header.Set("Accept", "application/json") + res, err := client.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return "", fmt.Errorf("Status code %d", res.StatusCode) + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", err + } + + var keys []Key + if err := json.Unmarshal(body, &keys); err != nil { + return "", err + } + for _, key := range keys { + if key.Album != nil && key.Album.ID == album.ID { + return key.Key, nil + } + } + return "", fmt.Errorf("No sharing key found for album '%s'", album.Name) + } else { + // Créer un nouveau lien partagé + jsonData := []byte(`{ + "albumId": "` + album.ID + `", + "allowDownload": true, + "allowUpload": false, + "showMetadata": true, + "type": "ALBUM" + }`) + req, err := http.NewRequest(http.MethodPost, ic.BaseURL+"/api/shared-links", bytes.NewBuffer(jsonData)) + if err != nil { + return "", err + } + req.Header.Set("x-api-key", ic.APIKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/octet-stream") + res, err := client.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode != 201 { + return "", fmt.Errorf("Status code %d", res.StatusCode) + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", err + } + + var key Key + if err := json.Unmarshal(body, &key); err != nil { + return "", err + } + return key.Key, nil + } +} + +// Récupérer la miniature d'un album +func (ic *ImmichClient) GetThumbnail(assetID string) ([]byte, error) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodGet, ic.BaseURL+"/api/assets/"+assetID+"/thumbnail?size=preview", nil) + if err != nil { + return nil, err + } + req.Header.Set("x-api-key", ic.APIKey) + req.Header.Set("Accept", "application/octet-stream") + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, fmt.Errorf("Status code %d", res.StatusCode) + } + + return ioutil.ReadAll(res.Body) +} diff --git a/immich-souvenirs/internals/whatsapp/whatsapp.go b/immich-souvenirs/internals/whatsapp/whatsapp.go new file mode 100644 index 0000000..5e58662 --- /dev/null +++ b/immich-souvenirs/internals/whatsapp/whatsapp.go @@ -0,0 +1,84 @@ +package whatsapp + +import ( + "context" + "fmt" + "os" + + _ "github.com/mattn/go-sqlite3" + "github.com/mdp/qrterminal/v3" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/store/sqlstore" + waLog "go.mau.fi/whatsmeow/util/log" + "go.mau.fi/whatsmeow/types" + waProto "go.mau.fi/whatsmeow/binary/proto" + "google.golang.org/protobuf/proto" +) + +type WhatsAppClient struct { + Client *whatsmeow.Client +} + +// Fonction pour initialiser la connexion et retourner une instance de WhatsAppClient +func New(sessionFile string) (*WhatsAppClient, error) { + dbLog := waLog.Stdout("Database", "ERROR", true) + container, err := sqlstore.New(context.Background(), "sqlite3", "file:"+sessionFile+"?_foreign_keys=on", dbLog) + if err != nil { + return nil, err + } + deviceStore, err := container.GetFirstDevice(context.Background()) + if err != nil { + return nil, err + } + clientLog := waLog.Stdout("Client", "ERROR", true) + client := whatsmeow.NewClient(deviceStore, clientLog) + + if client.Store.ID == nil { + // No ID stored, nouvelle connexion + qrChan, _ := client.GetQRChannel(context.Background()) + err = client.Connect() + if err != nil { + return nil, err + } + for evt := range qrChan { + if evt.Event == "code" { + qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) + fmt.Println("QR code:", evt.Code) + } else { + fmt.Println("Event:", evt.Event) + } + } + } else { + // Connexion existante + err = client.Connect() + if err != nil { + return nil, err + } + } + + return &WhatsAppClient{Client: client}, nil +} + +// Méthode pour envoyer un message +func (wac *WhatsAppClient) SendMessage(group string, title string, description string, message string, url string, thumbnail []byte) error { + jid, err := types.ParseJID(group) + if err != nil { + return fmt.Errorf("Incorrect group identifier '%s': %v", group, err) + } + + msg := &waProto.Message{ + ExtendedTextMessage: &waProto.ExtendedTextMessage{ + Title: proto.String(title), + Description: proto.String(description), + Text: proto.String(message), + MatchedText: proto.String(url), + JPEGThumbnail: thumbnail, + }, + } + ts, err := wac.Client.SendMessage(context.Background(), jid, msg) + if err != nil { + return fmt.Errorf("Error sending message with title '%s': %v", title, err) + } + fmt.Printf("Message with title '%s' sent (timestamp: %s)\n", title, ts) + return nil +} diff --git a/immich-souvenirs/test.sh b/immich-souvenirs/test.sh new file mode 100755 index 0000000..175536b --- /dev/null +++ b/immich-souvenirs/test.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +echo "-----------------------------------------------------------------------" +echo "apk add --no-cache git gcc musl-dev" +echo "go mod init github.com/napnap75/multiarch-docker-files/immich-souvenirs" +echo "go mod tidy" +echo "env CGO_ENABLED=1 env DEVELOPMENT-MODE=run-once go run immich-souvenirs.go" +echo "-----------------------------------------------------------------------" + +docker run -it -v $(pwd)/immich-souvenirs.go:/app/immich-souvenirs.go -v $(pwd)/internals:/app/internals -w /app -v immich-souvenirs_config:/config --env-file test.env --rm golang:1.23-alpine /bin/sh diff --git a/restic-auto/Dockerfile b/restic-auto/Dockerfile index 983ecba..e044f0d 100644 --- a/restic-auto/Dockerfile +++ b/restic-auto/Dockerfile @@ -7,11 +7,14 @@ RUN DOWNLOAD_ARCH=$(echo ${TARGETPLATFORM} | cut -d"/" -f 2) \ && DOWNLOAD_URL=$(curl -s https://api.github.com/repos/restic/restic/releases/latest | grep "browser_download_url" | grep "linux_"${DOWNLOAD_ARCH}"\." | cut -d\" -f4) \ && curl --retry 3 -L -s -o restic.bz2 ${DOWNLOAD_URL} \ && bunzip2 restic.bz2 \ - && chmod +x restic + && chmod +x restic \ + && DOWNLOAD_URL=$(curl -s https://api.github.com/repos/bdd/runitor/releases/latest | grep "browser_download_url" | grep "linux-"${DOWNLOAD_ARCH} | cut -d\" -f4) \ + && curl --retry 3 -L -s -o runitor ${DOWNLOAD_URL} \ + && chmod +x runitor FROM alpine:latest -COPY --from=builder restic /usr/bin/ +COPY --from=builder restic runitor /usr/bin/ RUN apk add --no-cache bash curl jq openssh-client dcron tzdata docker diff --git a/restic-auto/docker-entrypoint.sh b/restic-auto/docker-entrypoint.sh index 54aac7d..a83befc 100755 --- a/restic-auto/docker-entrypoint.sh +++ b/restic-auto/docker-entrypoint.sh @@ -37,7 +37,10 @@ else else echo -n "0 4 * * *" >> /tmp/crontab fi - echo -n " restic-auto >> /var/log/cron.log" >> /tmp/crontab + if [[ "$HC_PING_KEY" ]] ; then + echo -n " runitor -slug ${HOSTNAME}-restic-backup --" >> /tmp/crontab + fi + echo -n " restic-auto >> /var/log/cron.log" >> /tmp/crontab if [[ "$POST_BACKUP_COMMAND" ]] ; then echo -n " && $POST_BACKUP_COMMAND" >> /tmp/crontab fi @@ -47,7 +50,14 @@ else else echo -n "0 1 * * 0" >> /tmp/crontab fi - echo -n " restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --keep-yearly 2 --prune >> /var/log/cron.log && restic check >> /var/log/cron.log" >> /tmp/crontab + if [[ "$HC_PING_KEY" ]] ; then + echo -n " runitor -slug ${HOSTNAME}-restic-forget --" >> /tmp/crontab + fi + echo -n " restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --keep-yearly 2 --prune >> /var/log/cron.log &&" >> /tmp/crontab + if [[ "$HC_PING_KEY" ]] ; then + echo -n " runitor -slug ${HOSTNAME}-restic-check --" >> /tmp/crontab + fi + echo -n " restic check >> /var/log/cron.log" >> /tmp/crontab if [[ "$POST_MAINTENANCE_COMMAND" ]] ; then echo -n " && $POST_MAINTENANCE_COMMAND" >> /tmp/crontab fi diff --git a/restic-auto/restic-auto b/restic-auto/restic-auto index 2061e90..27f94b9 100755 --- a/restic-auto/restic-auto +++ b/restic-auto/restic-auto @@ -3,7 +3,10 @@ # Backup one directory function backup_dir { # Check if the dir to backup is mounted as a subdirectory of /root inside this container - if [ -d "/root_fs$1" ]; then + if [ "$1" = "" ]; then + echo "[ERROR] Cannot backup the root directory. Have you correctly configured the volumes to backup for this container ?" + return -1 + elif [ -d "/root_fs$1" ]; then restic backup $RESTIC_BACKUP_FLAGS /root_fs$1 return $? else diff --git a/sshd/Dockerfile b/sshd/Dockerfile index 8e41a35..ceeda61 100644 --- a/sshd/Dockerfile +++ b/sshd/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:latest AS builder +FROM alpine:3.21 AS builder ARG TARGETPLATFORM @@ -12,7 +12,7 @@ RUN apk add --no-cache curl jq \ && curl --retry 3 -L -s -o /tmp/s6-overlay.tar.xz $S6_DOWNLOAD_URL \ && tar -xf /tmp/s6-overlay.tar.xz -C /tmp/s6-overlay -FROM alpine:latest +FROM alpine:3:21 RUN apk add --no-cache bash curl openssh-server rsync rrsync borgbackup COPY --from=builder /tmp/s6-overlay / diff --git a/webhook/Dockerfile b/webhook/Dockerfile index ccccac1..b8a129f 100644 --- a/webhook/Dockerfile +++ b/webhook/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:alpine3.11 AS build +FROM golang:1-alpine AS build WORKDIR /go/src/github.com/adnanh/webhook RUN apk add --update -t build-deps curl libc-dev gcc libgcc jq \ && DOWNLOAD_URL=$(curl -s https://api.github.com/repos/adnanh/webhook/releases/latest | jq -r '.tarball_url') \ @@ -7,7 +7,7 @@ RUN apk add --update -t build-deps curl libc-dev gcc libgcc jq \ && go get -d \ && go build -o /usr/local/bin/webhook -FROM alpine:3.11 +FROM alpine:latest RUN apk add --no-cache openssl expect COPY --from=build /usr/local/bin/webhook /usr/local/bin/webhook WORKDIR /etc/webhook